@cardenelabs/cdl 0.40.0 → 0.40.2

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.0",
3
+ "version": "0.40.2",
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",
@@ -3,6 +3,7 @@ import type { ChartPieForm, LaidNode, Tone } from "../types";
3
3
  import { TONE, chartSeriesColor } from "../render/tone";
4
4
  import { resolveChartData, type ResolvedChartDatum } from "../render/payload-binding";
5
5
  import { 進みを畳む } from "./draw-progress";
6
+ import { 名札の段をほどく } from "./label-stack";
6
7
  import { 扇形の切り抜き } from "./draw-arc";
7
8
  import { textWidth } from "./text-width";
8
9
  import { 実値表記, 割合表記 } from "./chart-number";
@@ -191,6 +192,47 @@ export function ChartPieNode({
191
192
  /** その扇が開き始めたか。 開き始めた扇の凡例だけを出す */
192
193
  const 開き始めた = (startFrac: number): boolean => !drawing || 開き > startFrac;
193
194
 
195
+ /**
196
+ * 名札 1 つが縦に要する高さ。 割合と名前の 2 行ぶんに、行どうしが触れない余りを足す
197
+ */
198
+ const 名札の行 = 割合の級 + 名前の級 + 3;
199
+ /**
200
+ * 名札の縦の位置 (cdl#727)。
201
+ *
202
+ * **角度がそのまま縦になる** ので、小さい扇が続くと名札が同じ場所に集まる
203
+ * (実測で 0.3% の小片 2 個が完全に重なった)。 左右それぞれで重なりをほどく。
204
+ *
205
+ * 扇の縁の点は動かさない = 名札がどの扇のものかは引き出し線が保つ。
206
+ *
207
+ * **まだ開いていない扇も数に入れる**。 出ている名札だけでほどくと、扇が開くたびに
208
+ * 既に出ている名札が動く = 段の進みの途中で字が跳ねる。
209
+ */
210
+ const 名札の側 = slices.map((s) => Math.cos(s.中央角) >= 0);
211
+ const 名札の真のy = slices.map((s) => cy + (radius + 引き出し) * Math.sin(s.中央角));
212
+ const 名札のy = (() => {
213
+ const 出す = [...名札の真のy];
214
+ for (const 側 of [true, false]) {
215
+ const 番号 = 名札の側.flatMap((右, i) => (右 === 側 ? [i] : []));
216
+ if (番号.length === 0) continue;
217
+ const 直した = 名札の段をほどく(
218
+ 番号.map((i) => 名札の真のy[i]!),
219
+ 名札の行,
220
+ PAD_TOP + 名札の縦の張り出し,
221
+ node.h - PAD_BOTTOM - 名札の縦の張り出し,
222
+ );
223
+ 番号.forEach((i, k) => (出す[i] = 直した[k]!));
224
+ }
225
+ return 出す;
226
+ })();
227
+ /**
228
+ * 名札の横の位置。 **角度から決めず、円の外の決まった位置に置く** (cdl#727)。
229
+ *
230
+ * 角度から決めたままだと、縦をずらした名札が円の上へ乗る (実測)。 肘も同じ列に
231
+ * 揃えるので、引き出し線が円を横切らない = 肘は必ず半径の外にある。
232
+ */
233
+ const 肘のx = (右: boolean): number => cx + (右 ? 1 : -1) * (radius + 引き出し);
234
+ const 字のx = (右: boolean): number => 肘のx(右) + (右 ? 字までの間 : -字までの間);
235
+
194
236
 
195
237
  return (
196
238
  <g transform={`translate(${x0} ${y0})`}>
@@ -283,12 +325,12 @@ export function ChartPieNode({
283
325
  */}
284
326
  {slices.map((s, idx) => {
285
327
  if (!開き始めた(s.startFrac)) return null;
286
- const 右 = Math.cos(s.中央角) >= 0;
328
+ const 右 = 名札の側[idx]!;
287
329
  const 縁x = cx + radius * Math.cos(s.中央角);
288
330
  const 縁y = cy + radius * Math.sin(s.中央角);
289
- const 折れx = cx + (radius + 引き出し) * Math.cos(s.中央角);
290
- const 折れy = cy + (radius + 引き出し) * Math.sin(s.中央角);
291
- const 字x = 折れx + (右 ? 字までの間 : -字までの間);
331
+ const 折れx = 肘のx(右);
332
+ const 折れy = 名札のy[idx]!;
333
+ const 字x = 字のx(右);
292
334
  return (
293
335
  <g key={`callout-${idx}`}>
294
336
  <path
@@ -5,6 +5,7 @@ import { resolveChartData } from "../render/payload-binding";
5
5
  import { textWidth } from "./text-width";
6
6
  import { 実値表記 } from "./chart-number";
7
7
  import { 進みを畳む, 伸ばすdash } from "./draw-progress";
8
+ import { 名札の段をほどく } from "./label-stack";
8
9
 
9
10
  /**
10
11
  * chart-slope kind ... 2 時点を 1 本の直線でつなぎ、増減と順位の入れ替わりを見せる図 (cdl#688)。
@@ -68,6 +69,17 @@ import { 進みを畳む, 伸ばすdash } from "./draw-progress";
68
69
  *
69
70
  * **この形では「線の傾きが動いた量」 が成立しない**。 `+40` と `+30` がどちらも水平線に
70
71
  * なる。 書いた図だけに効く割り切りで、量は字を読む。
72
+ *
73
+ * ## 名札は重ならないようにほどく (cdl#723)
74
+ *
75
+ * 名札を点と同じ y に置くと、**値が同じ / 近い系列があると名札どうしが重なって読めない**。
76
+ * 実測で 59 と 59 の 2 件が同じ y になり、値も名前も二重に印字されていた。
77
+ *
78
+ * 動かすのは **名札だけ**。 点と線は真の位置に残す = 位置をずらすと、書いていない差を
79
+ * 絵が主張することになる (同順位を段でずらさないのと同じ理由)。 同じ値の点は重なったまま
80
+ * でよく、どの名札がどの系列かは名前の色が担う。
81
+ *
82
+ * ほどき方は `名札の段をほどく` が持つ。
71
83
  */
72
84
 
73
85
  /** 線を引くのに最低限要る横幅。 名札が長い図でもこれだけは残す */
@@ -218,6 +230,20 @@ export function ChartSlopeNode({
218
230
  /** 右の点と右の名札は、線が届いてから出る */
219
231
  const 右を出す = !drawing || 伸び >= 1;
220
232
 
233
+ // `rank` は縦の位置を順位の段にする (cdl#707)。 値の尺は使わない
234
+ const 左の点y = 系列たち.map((s, i) =>
235
+ 形 === "rank" ? 段のyを取る(前の順位[i]!) : yを取る(s.前),
236
+ );
237
+ const 右の点y = 系列たち.map((s, i) =>
238
+ 形 === "rank" ? 段のyを取る(今の順位[i]!) : yを取る(s.今),
239
+ );
240
+
241
+ /** 名札 1 行が縦に要する高さ。 大きい方の字に、行どうしが触れない余りを足す */
242
+ const 名札の行 = Math.max(値の級, 名前の級) + 4;
243
+ /** 名札の縦の位置 (cdl#723)。 点は真の位置のまま、字だけを重ならない段へ移す */
244
+ const 左の名札y = 前あり ? 名札の段をほどく(左の点y, 名札の行, 上端, 下端) : 左の点y;
245
+ const 右の名札y = 名札の段をほどく(右の点y, 名札の行, 上端, 下端);
246
+
221
247
  return (
222
248
  <g transform={`translate(${x0} ${y0})`}>
223
249
  <rect
@@ -276,9 +302,11 @@ export function ChartSlopeNode({
276
302
  )}
277
303
 
278
304
  {系列たち.map((s, i) => {
279
- // `rank` は縦の位置を順位の段にする (cdl#707)。 値の尺は使わない
280
- const 左y = 形 === "rank" ? 段のyを取る(前の順位[i]!) : yを取る(s.前);
281
- const 右y = 形 === "rank" ? 段のyを取る(今の順位[i]!) : yを取る(s.今);
305
+ const 左y = 左の点y[i]!;
306
+ const 右y = 右の点y[i]!;
307
+ // 名札は重なりをほどいた段に置く (cdl#723)
308
+ const 左札y = 左の名札y[i]!;
309
+ const 右札y = 右の名札y[i]!;
282
310
  const 未解決 = s.未解決 ? "true" : undefined;
283
311
  return (
284
312
  <g key={`slope-${i}`}>
@@ -321,7 +349,7 @@ export function ChartSlopeNode({
321
349
  <text
322
350
  data-cdl-role="chart-slope-value"
323
351
  x={左軸x - 軸から値まで}
324
- y={左y + 5}
352
+ y={左札y + 5}
325
353
  textAnchor="end"
326
354
  fontSize={値の級}
327
355
  fontWeight={600}
@@ -332,7 +360,7 @@ export function ChartSlopeNode({
332
360
  <text
333
361
  data-cdl-role="chart-slope-name"
334
362
  x={左軸x - 軸から値まで - 左値幅 - 値から名前まで}
335
- y={左y + 5}
363
+ y={左札y + 5}
336
364
  textAnchor="end"
337
365
  fontSize={名前の級}
338
366
  fill={s.色}
@@ -344,7 +372,7 @@ export function ChartSlopeNode({
344
372
  <text
345
373
  data-cdl-role="chart-slope-name"
346
374
  x={左軸x - 軸から値まで}
347
- y={右y + 5}
375
+ y={右札y + 5}
348
376
  textAnchor="end"
349
377
  fontSize={名前の級}
350
378
  fill={s.色}
@@ -360,7 +388,7 @@ export function ChartSlopeNode({
360
388
  // 消費側が増減だけに色を当てたくなる経路がある
361
389
  data-cdl-role={形 === "delta" ? "chart-slope-delta" : "chart-slope-value"}
362
390
  x={右軸x + 軸から値まで}
363
- y={右y + 5}
391
+ y={右札y + 5}
364
392
  fontSize={値の級}
365
393
  fontWeight={600}
366
394
  fill="var(--cdl-text, #1a1f2a)"
@@ -371,7 +399,7 @@ export function ChartSlopeNode({
371
399
  <text
372
400
  data-cdl-role="chart-slope-name"
373
401
  x={右軸x + 軸から値まで + 右値幅 + 値から名前まで}
374
- y={右y + 5}
402
+ y={右札y + 5}
375
403
  fontSize={名前の級}
376
404
  fill={s.色}
377
405
  >
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 名札の縦の重なりをほどく (cdl#723 / cdl#727)。
3
+ *
4
+ * 字の位置を値から決める図 (傾き図 / 円グラフ) は、**値が同じ / 近い件があると名札が
5
+ * 重なって読めない**。 実測で傾き図は 59 と 59 の 2 件が、円グラフは 0.3% の小片 2 個が
6
+ * 完全に重なっていた。
7
+ *
8
+ * ## 動かすのは名札だけ
9
+ *
10
+ * 点や扇は真の位置に残す。 位置をずらすと、書いていない差を絵が主張することになる
11
+ * (同順位を段でずらさないのと同じ理由、cdl#707)。 名札は図の量を表さないので動かしてよい。
12
+ */
13
+
14
+ /**
15
+ * 名札の縦の位置をほどく。
16
+ *
17
+ * ## 重なった連なりは元の位置の中心へ置き直す
18
+ *
19
+ * 上から順に押し下げるだけだと塊が下へ寄り、点との対応が片側だけ遠くなる。
20
+ * **元の位置からの動きが最も小さくなる並べ方** を選ぶ = 重なった連なりごとに、元の位置の
21
+ * 平均を中心にして間隔ぶんずつ均等に置く。
22
+ *
23
+ * 実装は単調回帰。 `y[k+1] - y[k] >= 間隔` は `y[k] - 間隔 * k` が単調非減少と同じなので、
24
+ * その量に隣接違反の併合を当てて戻す。 併合した塊は二度と割れないので、件数ぶんで終わる。
25
+ *
26
+ * @param 真のy 点の縦の位置。 並びは系列の順
27
+ * @param 間隔 名札 1 行が縦に要する高さ
28
+ * @param 上限 名札を置いてよい上端
29
+ * @param 下限 名札を置いてよい下端
30
+ * @returns 系列の順に対応する名札の縦の位置
31
+ */
32
+ export function 名札の段をほどく(
33
+ 真のy: readonly number[],
34
+ 間隔: number,
35
+ 上限: number,
36
+ 下限: number,
37
+ ): number[] {
38
+ const 件数 = 真のy.length;
39
+ if (件数 === 0) return [];
40
+ // 上から順に解く。 元の並び順は最後に戻す。 同じ y の件は系列の順で上下を決める
41
+ const 並び = 真のy.map((y, i) => ({ y, i })).sort((a, b) => a.y - b.y || a.i - b.i);
42
+ const 結果 = new Array<number>(件数);
43
+
44
+ /**
45
+ * **入り切らない図では均等に割り付ける**。 間隔を守る置き方が無いので、どれか 1 つを
46
+ * 枠の外へ出すのではなく全部を等しく詰める。 重なりは残るが、寄る先が偏らない。
47
+ */
48
+ if (間隔 * (件数 - 1) > 下限 - 上限) {
49
+ 並び.forEach((e, k) => {
50
+ 結果[e.i] = 件数 > 1 ? 上限 + ((下限 - 上限) / (件数 - 1)) * k : (上限 + 下限) / 2;
51
+ });
52
+ return 結果;
53
+ }
54
+
55
+ // 塊は「合計」 と「件数」 だけ持てば平均が出る
56
+ const 塊たち: { 合計: number; 件数: number }[] = [];
57
+ 並び.forEach((e, k) => {
58
+ 塊たち.push({ 合計: e.y - 間隔 * k, 件数: 1 });
59
+ while (塊たち.length >= 2) {
60
+ const 後 = 塊たち[塊たち.length - 1]!;
61
+ const 前 = 塊たち[塊たち.length - 2]!;
62
+ if (前.合計 / 前.件数 <= 後.合計 / 後.件数) break;
63
+ 塊たち.splice(塊たち.length - 2, 2, { 合計: 前.合計 + 後.合計, 件数: 前.件数 + 後.件数 });
64
+ }
65
+ });
66
+
67
+ const ほどいた: number[] = [];
68
+ 塊たち.forEach((塊) => {
69
+ const 平均 = 塊.合計 / 塊.件数;
70
+ for (let j = 0; j < 塊.件数; j++) ほどいた.push(平均 + 間隔 * ほどいた.length);
71
+ });
72
+
73
+ // 枠へ収める。 **全体を同じだけ動かす** ので、ほどいた間隔は崩れない
74
+ const ずれ = Math.max(0, 上限 - ほどいた[0]!) - Math.max(0, ほどいた[件数 - 1]! - 下限);
75
+ 並び.forEach((e, k) => (結果[e.i] = ほどいた[k]! + ずれ));
76
+ return 結果;
77
+ }