@cardenelabs/cdl 0.7.0 → 0.10.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +446 -0
  2. package/SPEC.md +42 -18
  3. package/dist/index.cjs +793 -347
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +73 -14
  6. package/dist/index.d.ts +73 -14
  7. package/dist/index.js +793 -347
  8. package/dist/index.js.map +1 -1
  9. package/dist/react.cjs +658 -236
  10. package/dist/react.cjs.map +1 -1
  11. package/dist/react.d.cts +1 -1
  12. package/dist/react.d.ts +1 -1
  13. package/dist/react.js +658 -236
  14. package/dist/react.js.map +1 -1
  15. package/dist/{render-DquCvgOB.d.cts → render-CH1Qn3Jv.d.cts} +47 -2
  16. package/dist/{render-DquCvgOB.d.ts → render-CH1Qn3Jv.d.ts} +47 -2
  17. package/package.json +6 -3
  18. package/src/builder.ts +13 -1
  19. package/src/kinds/box-lines.ts +120 -0
  20. package/src/kinds/chart-line.tsx +94 -25
  21. package/src/kinds/gantt.tsx +152 -24
  22. package/src/kinds/mind-map.tsx +50 -12
  23. package/src/kinds/quadrant.tsx +28 -2
  24. package/src/kinds/shape-blockchain.tsx +65 -12
  25. package/src/kinds/shape-region.tsx +152 -34
  26. package/src/kinds/tree.tsx +92 -20
  27. package/src/layout/collisions.ts +10 -2
  28. package/src/layout/footer-shape.ts +86 -24
  29. package/src/layout/lifeline.ts +80 -0
  30. package/src/layout/spec.ts +21 -2
  31. package/src/layout/text-width.ts +92 -4
  32. package/src/layout/viewbox.ts +0 -8
  33. package/src/layout.ts +8 -1
  34. package/src/presets.ts +120 -22
  35. package/src/render/header.tsx +3 -1
  36. package/src/render/nodes.tsx +15 -1
  37. package/src/render/payload-binding.ts +31 -8
  38. package/src/render/stage.tsx +21 -34
  39. package/src/render/template-fields.ts +212 -0
  40. package/src/render/utils.ts +32 -2
  41. package/src/render.tsx +2 -9
  42. package/src/types.ts +47 -2
  43. package/src/validate.ts +45 -13
  44. package/src/visual-validate.ts +34 -1
  45. package/src/layout/label-shift.ts +0 -28
@@ -148,6 +148,17 @@ type CdlNode = {
148
148
  */
149
149
  posW?: number;
150
150
  posH?: number;
151
+ /**
152
+ * 図の中での役割。 位置ではなく **組み立てた側が付ける目印** で、 描画側の分岐に使う。
153
+ *
154
+ * `lifeline-footer` = 順序図で lane の末尾に置く下端の名札 (`presets.ts` の `build()` が付ける)。
155
+ * 一部の `shape-` は絵を箱の下端から上へ積むため、 箱が低いと絵が縦線と重なる。 その回避で
156
+ * 絵を下げる対象がこの名札に限られる。
157
+ *
158
+ * **位置で推測してはいけない** = 手で組んだ図で一番下が名札でない `shape-` の node だと、
159
+ * その絵が下がってしまう (`#458`)。
160
+ */
161
+ role?: "lifeline-footer";
151
162
  /**
152
163
  * node の主色。 未指定なら kind ごとの既定色。
153
164
  *
@@ -208,6 +219,19 @@ type CdlNode = {
208
219
  * kinds/gantt.tsx が縦軸 task lane + 横軸日付 rect bar で SVG 描画する。
209
220
  */
210
221
  ganttData?: GanttTaskPayload[];
222
+ /**
223
+ * gantt-timeline の横軸を何目盛り分にするか (`#483`)。
224
+ *
225
+ * 書かない図では今までどおり全 task の最大値から自動で決まる。 書くと尺がその値で固定され、
226
+ * 状態で動く task があっても他の task の帯の位置と幅が変わらない。
227
+ *
228
+ * 位置は 0 から `ganttAxisMax - 1` までで、 そこから外れる task の帯は右端の目盛りに寄せて
229
+ * 描く (図の外へ出さない、 `kinds/gantt.tsx` SSOT)。
230
+ *
231
+ * 有限の正の数でなければ書かなかったものとして扱う = 尺が壊れて帯が図の外へ出るより、
232
+ * 自動に落ちる方が読める図になる。
233
+ */
234
+ ganttAxisMax?: number;
211
235
  /**
212
236
  * mind-map 専用の branch payload。 tree 構造を 1 node に格納し、
213
237
  * kinds/mind-map.tsx が中心 + 放射で SVG 描画する。
@@ -315,13 +339,20 @@ type QuadrantItemPayload = {
315
339
  quadrant: BoundEnum<QuadrantKey>;
316
340
  subtitle?: string;
317
341
  };
318
- /** tree-hierarchy kind の 1 node payload */
342
+ /**
343
+ * tree-hierarchy kind の 1 node payload。
344
+ *
345
+ * **上の小ラベル (`eyebrow`) は受けない** (#471)。 箱の高さは段の間隔から出ており
346
+ * (`Math.min(38, 間隔 * 0.7)`)、 名前と補足で 2 行を使うと 3 行目は入らない。 高さを広げると
347
+ * 段どうしが近づいて箱が重なる。 受け取って描かない欄は、 書き手から効いていないことが
348
+ * 読めないため置かない。
349
+ */
319
350
  type TreeNodePayload = {
320
351
  id: string;
321
352
  title: string;
322
353
  parent?: string;
354
+ /** 箱の 2 行目に出す補足 */
323
355
  subtitle?: string;
324
- eyebrow?: string;
325
356
  };
326
357
  /** journey-map の気持ち 5 段階 */
327
358
  type JourneyEmotion = "delighted" | "happy" | "neutral" | "frustrated" | "angry";
@@ -2016,6 +2047,20 @@ type CdlPhase = {
2016
2047
  title: string;
2017
2048
  body: string;
2018
2049
  activate: string[];
2050
+ /**
2051
+ * この phase で「左の起点から描き始める」 node の id (cdl#512)。
2052
+ *
2053
+ * `activate` と同じ形で node id を並べる。 対象の node は phase の進み (0→1) に合わせて
2054
+ * 描かれ、 進みが 1 に達した時点で全体が出る。 現在の対象は `chart-line` だけで、
2055
+ * 折れ線が左端から右へ伸び、 点と値の数字は線の先が届いた時に現れる。
2056
+ *
2057
+ * **`activate` を兼ねさせない**。 1 箱で図全体を描く種別 (`chart-*` 等) では焦点が
2058
+ * その箱に解決されるため、 連続する phase で光り続ける図が実在する。 「光っていたら
2059
+ * 描く」 にすると、 値が動くだけの phase でも線を引き直すことになる。
2060
+ *
2061
+ * 書かない phase では従来どおり全長で描く (DOM に dash 属性も付かない)。
2062
+ */
2063
+ draw?: string[];
2019
2064
  /** 数値 state の線形補間 (write phase で 100→90 等) */
2020
2065
  tweens: Array<{
2021
2066
  stateId: string;
@@ -148,6 +148,17 @@ type CdlNode = {
148
148
  */
149
149
  posW?: number;
150
150
  posH?: number;
151
+ /**
152
+ * 図の中での役割。 位置ではなく **組み立てた側が付ける目印** で、 描画側の分岐に使う。
153
+ *
154
+ * `lifeline-footer` = 順序図で lane の末尾に置く下端の名札 (`presets.ts` の `build()` が付ける)。
155
+ * 一部の `shape-` は絵を箱の下端から上へ積むため、 箱が低いと絵が縦線と重なる。 その回避で
156
+ * 絵を下げる対象がこの名札に限られる。
157
+ *
158
+ * **位置で推測してはいけない** = 手で組んだ図で一番下が名札でない `shape-` の node だと、
159
+ * その絵が下がってしまう (`#458`)。
160
+ */
161
+ role?: "lifeline-footer";
151
162
  /**
152
163
  * node の主色。 未指定なら kind ごとの既定色。
153
164
  *
@@ -208,6 +219,19 @@ type CdlNode = {
208
219
  * kinds/gantt.tsx が縦軸 task lane + 横軸日付 rect bar で SVG 描画する。
209
220
  */
210
221
  ganttData?: GanttTaskPayload[];
222
+ /**
223
+ * gantt-timeline の横軸を何目盛り分にするか (`#483`)。
224
+ *
225
+ * 書かない図では今までどおり全 task の最大値から自動で決まる。 書くと尺がその値で固定され、
226
+ * 状態で動く task があっても他の task の帯の位置と幅が変わらない。
227
+ *
228
+ * 位置は 0 から `ganttAxisMax - 1` までで、 そこから外れる task の帯は右端の目盛りに寄せて
229
+ * 描く (図の外へ出さない、 `kinds/gantt.tsx` SSOT)。
230
+ *
231
+ * 有限の正の数でなければ書かなかったものとして扱う = 尺が壊れて帯が図の外へ出るより、
232
+ * 自動に落ちる方が読める図になる。
233
+ */
234
+ ganttAxisMax?: number;
211
235
  /**
212
236
  * mind-map 専用の branch payload。 tree 構造を 1 node に格納し、
213
237
  * kinds/mind-map.tsx が中心 + 放射で SVG 描画する。
@@ -315,13 +339,20 @@ type QuadrantItemPayload = {
315
339
  quadrant: BoundEnum<QuadrantKey>;
316
340
  subtitle?: string;
317
341
  };
318
- /** tree-hierarchy kind の 1 node payload */
342
+ /**
343
+ * tree-hierarchy kind の 1 node payload。
344
+ *
345
+ * **上の小ラベル (`eyebrow`) は受けない** (#471)。 箱の高さは段の間隔から出ており
346
+ * (`Math.min(38, 間隔 * 0.7)`)、 名前と補足で 2 行を使うと 3 行目は入らない。 高さを広げると
347
+ * 段どうしが近づいて箱が重なる。 受け取って描かない欄は、 書き手から効いていないことが
348
+ * 読めないため置かない。
349
+ */
319
350
  type TreeNodePayload = {
320
351
  id: string;
321
352
  title: string;
322
353
  parent?: string;
354
+ /** 箱の 2 行目に出す補足 */
323
355
  subtitle?: string;
324
- eyebrow?: string;
325
356
  };
326
357
  /** journey-map の気持ち 5 段階 */
327
358
  type JourneyEmotion = "delighted" | "happy" | "neutral" | "frustrated" | "angry";
@@ -2016,6 +2047,20 @@ type CdlPhase = {
2016
2047
  title: string;
2017
2048
  body: string;
2018
2049
  activate: string[];
2050
+ /**
2051
+ * この phase で「左の起点から描き始める」 node の id (cdl#512)。
2052
+ *
2053
+ * `activate` と同じ形で node id を並べる。 対象の node は phase の進み (0→1) に合わせて
2054
+ * 描かれ、 進みが 1 に達した時点で全体が出る。 現在の対象は `chart-line` だけで、
2055
+ * 折れ線が左端から右へ伸び、 点と値の数字は線の先が届いた時に現れる。
2056
+ *
2057
+ * **`activate` を兼ねさせない**。 1 箱で図全体を描く種別 (`chart-*` 等) では焦点が
2058
+ * その箱に解決されるため、 連続する phase で光り続ける図が実在する。 「光っていたら
2059
+ * 描く」 にすると、 値が動くだけの phase でも線を引き直すことになる。
2060
+ *
2061
+ * 書かない phase では従来どおり全長で描く (DOM に dash 属性も付かない)。
2062
+ */
2063
+ draw?: string[];
2019
2064
  /** 数値 state の線形補間 (write phase で 100→90 等) */
2020
2065
  tweens: Array<{
2021
2066
  stateId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardenelabs/cdl",
3
- "version": "0.7.0",
3
+ "version": "0.10.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",
@@ -47,7 +47,8 @@
47
47
  "src",
48
48
  "examples",
49
49
  "README.md",
50
- "SPEC.md"
50
+ "SPEC.md",
51
+ "CHANGELOG.md"
51
52
  ],
52
53
  "publishConfig": {
53
54
  "access": "public"
@@ -62,7 +63,9 @@
62
63
  "typecheck": "tsc --noEmit",
63
64
  "test": "cd ../.. && vitest run packages/cdl",
64
65
  "lint": "tsc --noEmit",
65
- "prepublishOnly": "pnpm run typecheck && pnpm run build"
66
+ "prepublishOnly": "pnpm run typecheck && pnpm run build",
67
+ "prepack": "node scripts/prepack-check.mjs",
68
+ "postpack": "node scripts/prepack-check.mjs"
66
69
  },
67
70
  "peerDependencies": {
68
71
  "react": "^19.0.0",
package/src/builder.ts CHANGED
@@ -1355,6 +1355,11 @@ export type InputChain = {
1355
1355
 
1356
1356
  export type PhaseBuilder = {
1357
1357
  activate: (...ids: string[]) => PhaseBuilder;
1358
+ /**
1359
+ * この phase で左の起点から描き始める node を指定する (cdl#512)。
1360
+ * 対象は phase の進み (0→1) に合わせて描かれる。 現在の対象種別は `chart-line`。
1361
+ */
1362
+ draw: (...ids: string[]) => PhaseBuilder;
1358
1363
  /** 数値 state の線形補間 (write phase で 100→90 等) */
1359
1364
  tween: (stateId: string, from: number, to: number) => PhaseBuilder;
1360
1365
  /** 任意型 state を即時切替 (この phase 到達で value 上書き、 lerp なし) */
@@ -1364,6 +1369,7 @@ export type PhaseBuilder = {
1364
1369
 
1365
1370
  type PhaseAcc = {
1366
1371
  activate: string[];
1372
+ draw: string[];
1367
1373
  tweens: Array<{ stateId: string; from: number; to: number }>;
1368
1374
  sets: Array<{ stateId: string; value: string | number }>;
1369
1375
  badge?: string;
@@ -1375,6 +1381,10 @@ function makePhaseBuilder(acc: PhaseAcc): PhaseBuilder {
1375
1381
  acc.activate.push(...ids);
1376
1382
  return api;
1377
1383
  },
1384
+ draw(...ids) {
1385
+ acc.draw.push(...ids);
1386
+ return api;
1387
+ },
1378
1388
  tween(stateId, from, to) {
1379
1389
  acc.tweens.push({ stateId, from, to });
1380
1390
  return api;
@@ -3690,7 +3700,7 @@ export function diagram(
3690
3700
  },
3691
3701
  },
3692
3702
  phase(phaseId, opts, build) {
3693
- const acc: PhaseAcc = { activate: [], tweens: [], sets: [] };
3703
+ const acc: PhaseAcc = { activate: [], draw: [], tweens: [], sets: [] };
3694
3704
  build(makePhaseBuilder(acc));
3695
3705
  phases.push({
3696
3706
  id: phaseId,
@@ -3698,6 +3708,8 @@ export function diagram(
3698
3708
  title: opts.title,
3699
3709
  body: opts.body,
3700
3710
  activate: acc.activate,
3711
+ // 空なら欄ごと置かない。 置くと `draw` を使わない図の JSON の形が変わる
3712
+ ...(acc.draw.length > 0 ? { draw: acc.draw } : {}),
3701
3713
  tweens: acc.tweens,
3702
3714
  sets: acc.sets,
3703
3715
  badge: acc.badge,
@@ -0,0 +1,120 @@
1
+ import { textWidth } from "./text-width";
2
+
3
+ /**
4
+ * 箱の中に文字の行を積む (#471)。
5
+ *
6
+ * **JSX を持たない module に置く**。 木 (`tree.tsx`) と放射 (`mind-map.tsx`) の両方が使う。
7
+ * `text-width.ts` が同じ理由で分かれているのに合わせる。
8
+ *
9
+ * 箱の大きさは呼び出し側が決めた値をそのまま受ける = **積んだ結果で高さを変えない**。
10
+ * 木も放射も箱の高さが段の間隔から出ており (`Math.min(38, 間隔 * 0.7)`)、 高さを変えると
11
+ * 段どうしの間隔まで動いて既存の図の見た目が変わる。 入らない行は落とす方を採る。
12
+ */
13
+
14
+ /**
15
+ * 1 行が字の大きさの何倍の高さを占めるか。
16
+ *
17
+ * 詰め気味にするのは、 箱の高さが 30 前後しかないため (放射の枝 = 30、 木 = 最大 38)。
18
+ * 読みやすさの目安である 1.4-1.5 を採ると 2 行が入らず、 補足が常に落ちる。
19
+ */
20
+ const 行送りの比 = 1.15;
21
+
22
+ /** 大文字の高さが字の大きさの何倍か (baseline を出すのに使う) */
23
+ const 字の高さの比 = 0.72;
24
+
25
+ /** 箱の上下に空ける余白 */
26
+ const 上下の余白 = 2;
27
+
28
+ /** 箱の左右に空ける余白 (片側) */
29
+ const 左右の余白 = 8;
30
+
31
+ export type 積む行 = {
32
+ 役割: "title" | "subtitle";
33
+ text: string;
34
+ fontSize: number;
35
+ };
36
+
37
+ export type 積んだ行 = 積む行 & {
38
+ /** 箱の中心から見た baseline の上下のずれ */
39
+ dy: number;
40
+ };
41
+
42
+ /**
43
+ * 幅に収まるところまでで切り、 切った時は末尾に `…` を付ける。
44
+ *
45
+ * 字の幅は実測の見積り (`textWidth`) から出す。 SVG は描く前に実寸を返さないため、
46
+ * 描いてから測って縮める形は取れない。
47
+ *
48
+ * `測る` を差し替えられるのは、 等幅の書体を使う絵があるため (#469)。 等幅は字ごとの幅を
49
+ * 持たず字数で決まるので、 比例用の見積りを当てると実物とずれる。
50
+ */
51
+ export function 幅で切る(
52
+ text: string,
53
+ fontSize: number,
54
+ 使える幅: number,
55
+ 測る: (s: string, size: number) => number = textWidth,
56
+ ): string {
57
+ if (使える幅 <= 0) return "";
58
+ if (測る(text, fontSize) <= 使える幅) return text;
59
+
60
+ const 省略 = "…";
61
+ const 省略の幅 = 測る(省略, fontSize);
62
+ // 省略記号すら入らない幅では何も返さない。 返すと「幅に収まる文字を返す」 という約束を
63
+ // 破り、 箱から出る (深い放射や葉の多い木で内側の幅が極小になった時に起きる)
64
+ if (省略の幅 > 使える幅) return "";
65
+
66
+ let 出 = "";
67
+ let 幅 = 0;
68
+ for (const c of 書記素に分ける(text)) {
69
+ const 字の幅 = 測る(c, fontSize);
70
+ if (幅 + 字の幅 + 省略の幅 > 使える幅) break;
71
+ 幅 += 字の幅;
72
+ 出 += c;
73
+ }
74
+ return `${出}${省略}`;
75
+ }
76
+
77
+ /**
78
+ * 見た目の 1 字 (書記素クラスタ) に分ける。
79
+ *
80
+ * `for...of` は符号位置で回るため、 濁点や結合文字の付いた字と、 ZWJ で繋いだ絵文字を
81
+ * **途中で切る** (実測 = `é` が `e` だけになり、 `👩‍💻` が `👩‍` になった)。 幅の見積り
82
+ * (`textWidth`) も符号位置で足すので数は合うが、 見た目の 1 字が壊れる。
83
+ *
84
+ * `Intl.Segmenter` を持たない環境では符号位置に落ちる。 切り方が粗くなるだけで、 幅の
85
+ * 見積りとは食い違わない。
86
+ */
87
+ function 書記素に分ける(text: string): string[] {
88
+ const 区切り = (Intl as { Segmenter?: new (l?: string, o?: { granularity: string }) => { segment: (s: string) => Iterable<{ segment: string }> } }).Segmenter;
89
+ if (typeof 区切り !== "function") return [...text];
90
+ return [...new 区切り(undefined, { granularity: "grapheme" }).segment(text)].map((g) => g.segment);
91
+ }
92
+
93
+ /**
94
+ * 箱に入る行だけを選び、 それぞれの baseline を返す。
95
+ *
96
+ * 先頭 (名前) は必ず残す。 入り切らない時は **後ろから落とす** = 補足より名前を優先する。
97
+ * 2 件目以降の空文字は行として数えない (補足を渡していない時に空行が空くのを避ける)。
98
+ *
99
+ * 返す `dy` は箱の中心から見たずれで、 行の塊を上下の中央に置いた時の baseline になる。
100
+ */
101
+ export function 箱に積む(行: 積む行[], 箱の幅: number, 箱の高さ: number): 積んだ行[] {
102
+ const 候補 = 行.filter((r, i) => i === 0 || r.text.trim() !== "");
103
+ if (候補.length === 0) return [];
104
+
105
+ const 使える高さ = 箱の高さ - 上下の余白 * 2;
106
+ const 高さ = (rs: 積む行[]): number => rs.reduce((a, r) => a + r.fontSize * 行送りの比, 0);
107
+
108
+ // 入るまで後ろから落とす。 先頭は落とさない
109
+ const 残す = [...候補];
110
+ while (残す.length > 1 && 高さ(残す) > 使える高さ) 残す.pop();
111
+
112
+ const 使える幅 = 箱の幅 - 左右の余白 * 2;
113
+ const 総高 = 高さ(残す);
114
+ let 積み = -総高 / 2;
115
+ return 残す.map((r) => {
116
+ const dy = 積み + ((行送りの比 + 字の高さの比) / 2) * r.fontSize;
117
+ 積み += r.fontSize * 行送りの比;
118
+ return { ...r, text: 幅で切る(r.text, r.fontSize, 使える幅), dy };
119
+ });
120
+ }
@@ -7,15 +7,29 @@ import { resolveChartData } from "../render/payload-binding";
7
7
  * layout は node bbox (node.w × node.h) 内側に padding (top/right/bottom/left) を確保し、
8
8
  * その内側 rect を chart canvas とみなす。 縦軸 = value min-max、 横軸 = datum index。
9
9
  * 各 value は数でも `{signal}` でもよく、 後者は状態が動くたび折れ線が追随する。
10
+ *
11
+ * phase の `draw` にこの node の id が載っている間は、 折れ線が左の起点から伸びる (cdl#512)。
12
+ * 伸ばし方は dash (`pathLength` / `strokeDasharray` / `strokeDashoffset`) で、 `points` は
13
+ * 進みに関わらず全点を持つ。 点と値の数字は線の先が自分の位置に届いた時に現れ、
14
+ * 軸 / 格子 / 横軸の名前は最初から出る。
10
15
  */
11
16
  export function ChartLineNode({
12
17
  node,
13
18
  active,
14
19
  stateValues = {},
20
+ drawing = false,
21
+ progress = 1,
15
22
  }: {
16
23
  node: LaidNode;
17
24
  active: boolean;
18
25
  stateValues?: Record<string, string>;
26
+ /**
27
+ * この phase の `draw` に載っているか (cdl#512)。 true の間だけ折れ線が左の起点から
28
+ * 伸び、 点と値の数字は線の先が自分の位置に届いた時に現れる。
29
+ */
30
+ drawing?: boolean;
31
+ /** phase の進み (0→1)。 `drawing` が true の時だけ読む */
32
+ progress?: number;
19
33
  }): JSX.Element {
20
34
  const x0 = node.cx - node.w / 2;
21
35
  const y0 = node.cy - node.h / 2;
@@ -45,6 +59,44 @@ export function ChartLineNode({
45
59
 
46
60
  const points = data.map((d, idx) => `${xAt(idx)},${yAt(d.value)}`).join(" ");
47
61
 
62
+ // dash は polyline の実際の経路長で進むため、 点の表示判定も同じ経路長に揃える。
63
+ // 点番号の比率では、 急傾斜と平坦な区間が混ざる線で先端と点がずれる。
64
+ const 点までの長さ: number[] = [];
65
+ let 線の全長 = 0;
66
+ let 前の点: { x: number; y: number } | undefined;
67
+ for (const [idx, datum] of data.entries()) {
68
+ const 今の点 = { x: xAt(idx), y: yAt(datum.value) };
69
+ if (前の点) {
70
+ 線の全長 += Math.hypot(今の点.x - 前の点.x, 今の点.y - 前の点.y);
71
+ }
72
+ 点までの長さ.push(線の全長);
73
+ 前の点 = 今の点;
74
+ }
75
+
76
+ /**
77
+ * 線がどこまで伸びたか (0..1、 cdl#512)。 描く指定が無ければ常に 1 = 全長。
78
+ *
79
+ * 進みは呼出側から来る値なので、 範囲の外 (負 / 1 超 / NaN) が入りうる。 そのまま
80
+ * `strokeDashoffset` に渡すと線が消えたり逆向きに縮んだりするため、 ここで畳む。
81
+ */
82
+ const 伸び = drawing ? Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 1)) : 1;
83
+
84
+ /**
85
+ * その点まで線の先が届いたか。
86
+ *
87
+ * 点の位置は polyline の累積経路長で判定する。 **左端は進み 0 でも出す** = そこが
88
+ * 線の起点で、 起点が無いと線がどこから伸びているのか読めない。
89
+ *
90
+ * 点が 1 つの図では線を引かない (下の `data.length > 1`) ため、 割り算の分母が 0 に
91
+ * なる形には入らないが、 呼出の順に依らず成り立つよう先に返す。
92
+ */
93
+ const 線の先が届いた = (idx: number): boolean => {
94
+ if (!drawing) return true;
95
+ if (data.length <= 1) return true;
96
+ if (線の全長 === 0) return true;
97
+ return 伸び >= (点までの長さ[idx] ?? 0) / 線の全長;
98
+ };
99
+
48
100
  const gridCount = 4;
49
101
  const gridTicks = Array.from({ length: gridCount + 1 }, (_, i) => {
50
102
  const v = vMin + (vRange * i) / gridCount;
@@ -114,6 +166,16 @@ export function ChartLineNode({
114
166
  strokeWidth={2.5}
115
167
  strokeLinejoin="round"
116
168
  strokeLinecap="round"
169
+ // 伸ばすのは dash で行い、 **`points` は触らない** (cdl#512)。 点を削る形にすると
170
+ // 利用側が数えている点の数が進みの途中で変わる (dragon の `kind-geometry-check`)。
171
+ //
172
+ // `pathLength={1}` を置くのは、 実長を測るには DOM の ref が要り、 静的な書き出しでは
173
+ // 測れないため。 長さを 1 に正規化すれば進みをそのまま offset に使える。
174
+ //
175
+ // 描く指定が無い図には属性を 1 つも足さない = 既存の図の DOM を変えない
176
+ {...(drawing
177
+ ? { pathLength: 1, strokeDasharray: 1, strokeDashoffset: 1 - 伸び }
178
+ : {})}
117
179
  />
118
180
  )}
119
181
  {data.map((d, idx) => {
@@ -129,33 +191,40 @@ export function ChartLineNode({
129
191
  (prev === undefined || d.value <= prev) && (next === undefined || d.value <= next);
130
192
  const labelAbove = !isValley;
131
193
  const labelY = labelAbove ? cy - 12 : cy + 18;
194
+ // 点と値の数字は線の先に合わせて現れる。 **横軸の名前は最初から出す** =
195
+ // 名前は図の枠 (軸 / 格子) の側で、 これが順に増えると軸そのものが伸びて見える
196
+ const 出す = 線の先が届いた(idx);
132
197
  return (
133
198
  <g key={`pt-${idx}`} data-cdl-unresolved={d.unresolved ? "true" : undefined}>
134
- <circle
135
- cx={cx}
136
- cy={cy}
137
- r={5}
138
- fill="var(--cdl-node-fill, #ffffff)"
139
- stroke="var(--cdl-tone-accent, #2d6a8f)"
140
- strokeWidth={2.5}
141
- />
142
- <circle
143
- cx={cx}
144
- cy={cy}
145
- r={2.5}
146
- fill="var(--cdl-tone-accent, #2d6a8f)"
147
- />
148
- <text
149
- data-cdl-role="chart-line-value"
150
- x={cx}
151
- y={labelY}
152
- fontSize={11}
153
- fontWeight={600}
154
- textAnchor="middle"
155
- fill="var(--cdl-text, #1a1f2a)"
156
- >
157
- {d.value.toLocaleString()}
158
- </text>
199
+ {出す && (
200
+ <>
201
+ <circle
202
+ cx={cx}
203
+ cy={cy}
204
+ r={5}
205
+ fill="var(--cdl-node-fill, #ffffff)"
206
+ stroke="var(--cdl-tone-accent, #2d6a8f)"
207
+ strokeWidth={2.5}
208
+ />
209
+ <circle
210
+ cx={cx}
211
+ cy={cy}
212
+ r={2.5}
213
+ fill="var(--cdl-tone-accent, #2d6a8f)"
214
+ />
215
+ <text
216
+ data-cdl-role="chart-line-value"
217
+ x={cx}
218
+ y={labelY}
219
+ fontSize={11}
220
+ fontWeight={600}
221
+ textAnchor="middle"
222
+ fill="var(--cdl-text, #1a1f2a)"
223
+ >
224
+ {d.value.toLocaleString()}
225
+ </text>
226
+ </>
227
+ )}
159
228
  <text
160
229
  x={cx}
161
230
  y={PAD_TOP + canvasH + 20}