@cardenelabs/cdl 0.31.1 → 0.33.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.
@@ -0,0 +1,323 @@
1
+ import { type JSX } from "react";
2
+ import type { LaidNode, Tone } from "../types";
3
+ import { TONE, chartSeriesColor } from "../render/tone";
4
+ import { resolveChartData } from "../render/payload-binding";
5
+ import { textWidth } from "./text-width";
6
+ import { 実値表記 } from "./chart-number";
7
+ import { 進みを畳む, 伸ばすdash } from "./draw-progress";
8
+
9
+ /**
10
+ * chart-slope kind ... 2 時点を 1 本の直線でつなぎ、増減と順位の入れ替わりを見せる図 (cdl#688)。
11
+ *
12
+ * ## 何のための種別か
13
+ *
14
+ * 2 時点を並べる図は既に 2 つあるが、どちらもこの読み方を持たない。
15
+ *
16
+ * | 種別 | 見せるもの | 足りないもの |
17
+ * |---|---|---|
18
+ * | `chart-stacked-bar` (cdl#551) | 2 時点の構成比 | 全体を 100% に正規化するので、全体が伸びた / 縮んだが消える |
19
+ * | `chart-line` (cdl#513) | 3 点以上の推移 | 横軸が datum の並びなので 1 node = 1 本になり、系列ごとに 1 本にならない |
20
+ *
21
+ * 左右に縦軸を置いて系列ごとに 2 点を直線で結ぶと、3 つが同時に読める。
22
+ * 線の向きが増減、線の傾きが動いた量、線の交差が順位の入れ替わり。
23
+ *
24
+ * ## 前の時点は datum の `previous` で持つ
25
+ *
26
+ * `chart-stacked-bar` と同じ欄をそのまま読む。 新しい欄は足さない。
27
+ *
28
+ * **1 件も持たない図は線を引かない**。 2 点が無いと傾きが定義できないので、軸 1 本に点を
29
+ * 並べた形で出す。 持たない件だけを 0 として立ち上げるのは、他の件が前を持つ図に限る =
30
+ * その図では「前は無かった」 が読みとして成立する (`chart-stacked-bar` の扱いと揃える)。
31
+ *
32
+ * ## 尺は 0 に固定しない
33
+ *
34
+ * 近い値どうしの差を傾きで読ませる図なので、0 まで伸ばすと線が全部水平に潰れる。
35
+ * 両時点の全値の最小と最大から取る。 全部同じ値の図では縦の中央に置く (0 で割らない)。
36
+ *
37
+ * ## 名札は値を軸側に置く
38
+ *
39
+ * 左は 名前 → 値、右は 値 → 名前 の順で軸から外へ並べる。 値を軸に近い側へ置くと、
40
+ * 左右とも値の列が縦に揃って読み比べられる。
41
+ */
42
+
43
+ /** 線を引くのに最低限要る横幅。 名札が長い図でもこれだけは残す */
44
+ export const 線の最小幅 = 120;
45
+
46
+ type 系列 = {
47
+ 名前: string;
48
+ 前: number;
49
+ 今: number;
50
+ 色: string;
51
+ 未解決: boolean;
52
+ };
53
+
54
+ export function ChartSlopeNode({
55
+ node,
56
+ active,
57
+ stateValues = {},
58
+ drawing = false,
59
+ progress = 1,
60
+ }: {
61
+ node: LaidNode;
62
+ active: boolean;
63
+ stateValues?: Record<string, string>;
64
+ /** 段の `draw` にこの node が載っている間だけ true */
65
+ drawing?: boolean;
66
+ progress?: number;
67
+ }): JSX.Element {
68
+ const x0 = node.cx - node.w / 2;
69
+ const y0 = node.cy - node.h / 2;
70
+ const data = resolveChartData(node.chartData ?? [], stateValues);
71
+
72
+ const PAD = 24;
73
+ const 時点の級 = 12;
74
+ const 値の級 = 14;
75
+ const 名前の上限級 = 13;
76
+ const 名前の下限級 = 9;
77
+ const 軸から値まで = 10;
78
+ const 値から名前まで = 8;
79
+
80
+ const 前あり = data.some((d) => d.previous !== undefined);
81
+ const 系列たち: 系列[] = data.map((d, i) => ({
82
+ 名前: d.label ?? "",
83
+ // 前を持たない件は 0 から立ち上げる。 件を落とすと左右で系列の数が変わり、色の並びが
84
+ // 対応しなくなる (`chart-stacked-bar` と同じ判断)
85
+ 前: d.previous ?? 0,
86
+ 今: d.value,
87
+ 色: 印の色(d.tone, i),
88
+ 未解決: d.unresolved === true,
89
+ }));
90
+
91
+ const 全部の値 = 前あり
92
+ ? 系列たち.flatMap((s) => [s.前, s.今])
93
+ : 系列たち.map((s) => s.今);
94
+ const 最小 = 全部の値.length > 0 ? Math.min(...全部の値) : 0;
95
+ const 最大 = 全部の値.length > 0 ? Math.max(...全部の値) : 0;
96
+ const 尺の幅 = 最大 - 最小;
97
+
98
+ const 上端 = PAD + (前あり ? 時点の級 + 12 : 0) + 6;
99
+ const 下端 = Math.max(上端 + 10, node.h - PAD - 6);
100
+ const yを取る = (v: number): number =>
101
+ 尺の幅 > 0 ? 下端 - ((v - 最小) / 尺の幅) * (下端 - 上端) : (上端 + 下端) / 2;
102
+
103
+ const 左の字 = 系列たち.map((s) => 実値表記(s.前));
104
+ const 右の字 = 系列たち.map((s) => 実値表記(s.今));
105
+ const 左値幅 = Math.max(0, ...左の字.map((t) => textWidth(t, 値の級)));
106
+ const 右値幅 = Math.max(0, ...右の字.map((t) => textWidth(t, 値の級)));
107
+
108
+ /** 名札に回せる横幅。 線の幅を先に取り分けてから、残りを左右へ半分ずつ配る */
109
+ const 名前に使える = Math.max(
110
+ 0,
111
+ (node.w -
112
+ PAD * 2 -
113
+ 線の最小幅 -
114
+ (左値幅 + 軸から値まで + 値から名前まで) -
115
+ (右値幅 + 軸から値まで + 値から名前まで)) /
116
+ 2,
117
+ );
118
+ const 名前の級 = 収める級(系列たち.map((s) => s.名前), 名前に使える, 名前の上限級, 名前の下限級);
119
+ const 名前幅 = Math.min(
120
+ 名前に使える,
121
+ Math.max(0, ...系列たち.map((s) => textWidth(s.名前, 名前の級))),
122
+ );
123
+
124
+ const 左の余白 = 名前幅 + 値から名前まで + 左値幅 + 軸から値まで;
125
+ const 右の余白 = 軸から値まで + 右値幅 + 値から名前まで + 名前幅;
126
+
127
+ /**
128
+ * 軸の位置。 名札が長い図では余白の言い値どおりに置くと軸が交差するため、
129
+ * **線の幅を先に確保してから** 内側へ寄せる。 箱の内側から出さないことを優先する。
130
+ */
131
+ const 左軸x = 前あり
132
+ ? Math.max(PAD, Math.min(PAD + 左の余白, node.w / 2 - 線の最小幅 / 2))
133
+ : Math.max(PAD, Math.min(node.w / 2, node.w - PAD));
134
+ const 右軸x = 前あり
135
+ ? Math.min(node.w - PAD, Math.max(node.w - PAD - 右の余白, node.w / 2 + 線の最小幅 / 2))
136
+ : 左軸x;
137
+
138
+ const 伸び = 進みを畳む(drawing, progress);
139
+ /** 右の点と右の名札は、線が届いてから出る */
140
+ const 右を出す = !drawing || 伸び >= 1;
141
+
142
+ return (
143
+ <g transform={`translate(${x0} ${y0})`}>
144
+ <rect
145
+ data-cdl-role="node-body"
146
+ x={0}
147
+ y={0}
148
+ width={node.w}
149
+ height={node.h}
150
+ rx={16}
151
+ fill="var(--cdl-node-fill, #ffffff)"
152
+ stroke={active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)"}
153
+ strokeWidth={active ? 3 : 1.25}
154
+ />
155
+
156
+ {(前あり ? [左軸x, 右軸x] : [左軸x]).map((x, i) => (
157
+ <line
158
+ key={`axis-${i}`}
159
+ data-cdl-role="chart-slope-axis"
160
+ x1={x}
161
+ y1={上端 - 6}
162
+ x2={x}
163
+ y2={下端 + 6}
164
+ stroke="var(--cdl-divider, #d4d4d8)"
165
+ strokeWidth={1}
166
+ />
167
+ ))}
168
+
169
+ {/* 時点の名前。 軸が 1 本の図では描かない (比べる相手がいない) */}
170
+ {前あり && (
171
+ <>
172
+ <text
173
+ data-cdl-role="chart-slope-period"
174
+ x={左軸x}
175
+ y={PAD + 時点の級}
176
+ textAnchor="middle"
177
+ fontSize={時点の級}
178
+ fontWeight={700}
179
+ fill="var(--cdl-text-dim, #5a6270)"
180
+ >
181
+ 前
182
+ </text>
183
+ <text
184
+ data-cdl-role="chart-slope-period"
185
+ x={右軸x}
186
+ y={PAD + 時点の級}
187
+ textAnchor="middle"
188
+ fontSize={時点の級}
189
+ fontWeight={700}
190
+ fill="var(--cdl-text-dim, #5a6270)"
191
+ >
192
+ 今
193
+ </text>
194
+ </>
195
+ )}
196
+
197
+ {系列たち.map((s, i) => {
198
+ const 左y = yを取る(s.前);
199
+ const 右y = yを取る(s.今);
200
+ const 未解決 = s.未解決 ? "true" : undefined;
201
+ return (
202
+ <g key={`slope-${i}`}>
203
+ {前あり && (
204
+ <path
205
+ data-cdl-role="chart-slope-line"
206
+ data-cdl-unresolved={未解決}
207
+ d={`M ${左軸x} ${左y} L ${右軸x} ${右y}`}
208
+ fill="none"
209
+ stroke={s.色}
210
+ strokeWidth={2.5}
211
+ strokeLinecap="round"
212
+ {...伸ばすdash(drawing, 伸び)}
213
+ />
214
+ )}
215
+ {前あり && (
216
+ <circle
217
+ data-cdl-role="chart-slope-dot"
218
+ data-cdl-unresolved={未解決}
219
+ cx={左軸x}
220
+ cy={左y}
221
+ r={4}
222
+ fill={s.色}
223
+ />
224
+ )}
225
+ {右を出す && (
226
+ <circle
227
+ data-cdl-role="chart-slope-dot"
228
+ data-cdl-unresolved={未解決}
229
+ cx={右軸x}
230
+ cy={右y}
231
+ r={4}
232
+ fill={s.色}
233
+ />
234
+ )}
235
+
236
+ {/* 左の名札。 軸が 1 本の図では名前だけを左に置く */}
237
+ {前あり ? (
238
+ <>
239
+ <text
240
+ data-cdl-role="chart-slope-label"
241
+ x={左軸x - 軸から値まで}
242
+ y={左y + 5}
243
+ textAnchor="end"
244
+ fontSize={値の級}
245
+ fontWeight={600}
246
+ fill="var(--cdl-text, #1a1f2a)"
247
+ >
248
+ {左の字[i]}
249
+ </text>
250
+ <text
251
+ data-cdl-role="chart-slope-label"
252
+ x={左軸x - 軸から値まで - 左値幅 - 値から名前まで}
253
+ y={左y + 5}
254
+ textAnchor="end"
255
+ fontSize={名前の級}
256
+ fill="var(--cdl-text-dim, #5a6270)"
257
+ >
258
+ {s.名前}
259
+ </text>
260
+ </>
261
+ ) : (
262
+ <text
263
+ data-cdl-role="chart-slope-label"
264
+ x={左軸x - 軸から値まで}
265
+ y={右y + 5}
266
+ textAnchor="end"
267
+ fontSize={名前の級}
268
+ fill="var(--cdl-text-dim, #5a6270)"
269
+ >
270
+ {s.名前}
271
+ </text>
272
+ )}
273
+
274
+ {右を出す && (
275
+ <>
276
+ <text
277
+ data-cdl-role="chart-slope-label"
278
+ x={右軸x + 軸から値まで}
279
+ y={右y + 5}
280
+ fontSize={値の級}
281
+ fontWeight={600}
282
+ fill="var(--cdl-text, #1a1f2a)"
283
+ >
284
+ {右の字[i]}
285
+ </text>
286
+ {前あり && (
287
+ <text
288
+ data-cdl-role="chart-slope-label"
289
+ x={右軸x + 軸から値まで + 右値幅 + 値から名前まで}
290
+ y={右y + 5}
291
+ fontSize={名前の級}
292
+ fill="var(--cdl-text-dim, #5a6270)"
293
+ >
294
+ {s.名前}
295
+ </text>
296
+ )}
297
+ </>
298
+ )}
299
+ </g>
300
+ );
301
+ })}
302
+ </g>
303
+ );
304
+ }
305
+
306
+ /** 印の色。 明示した色が優先され、無ければ図表の系列の色を順に配る */
307
+ function 印の色(tone: Tone | undefined, idx: number): string {
308
+ if (tone) return TONE[tone];
309
+ return chartSeriesColor(idx);
310
+ }
311
+
312
+ /**
313
+ * 名前の字の大きさ。 **全部を同じ大きさに揃える** = 名前ごとに変えると列の見え方が乱れる。
314
+ *
315
+ * 最長の名前が枠に収まるまで 1 ずつ下げ、下限で止める。 下限でも収まらない図では
316
+ * 名札が枠から出るが、線と軸は箱の内側に残る。
317
+ */
318
+ function 収める級(名前たち: readonly string[], 幅: number, 上限: number, 下限: number): number {
319
+ if (名前たち.length === 0) return 上限;
320
+ let 級 = 上限;
321
+ while (級 > 下限 && Math.max(...名前たち.map((n) => textWidth(n, 級))) > 幅) 級 -= 1;
322
+ return 級;
323
+ }
@@ -90,6 +90,7 @@ const KINDS_WITHOUT_TITLE_TEXT: ReadonlySet<string> = new Set([
90
90
  "chart-stat",
91
91
  "chart-waffle",
92
92
  "chart-stacked-bar",
93
+ "chart-slope",
93
94
  "gantt-timeline",
94
95
  "mind-map",
95
96
  "funnel-stages",
@@ -62,6 +62,8 @@ export const TOKENS = {
62
62
  "chart-waffle": { w: 640, h: 320 },
63
63
  // 帯を最大 2 本 + 下に一覧。 縦は帯 2 本ぶんで足りる (cdl#551)
64
64
  "chart-stacked-bar": { w: 640, h: 320 },
65
+ // 左右 2 本の縦軸と、その外に名札。 縦は系列が縦に並ぶぶん要る (cdl#688)
66
+ "chart-slope": { w: 640, h: 360 },
65
67
  // Timeline / Mind 系 (CAR-994 Phase B)
66
68
  "gantt-timeline": { w: 720, h: 360 },
67
69
  "mind-map": { w: 720, h: 480 },
package/src/presets.ts CHANGED
@@ -2033,7 +2033,8 @@ export type ChartType =
2033
2033
  | "radial"
2034
2034
  | "stat"
2035
2035
  | "waffle"
2036
- | "stacked-bar";
2036
+ | "stacked-bar"
2037
+ | "slope";
2037
2038
 
2038
2039
  export type ChartDatum = {
2039
2040
  id: string;
@@ -2100,6 +2101,7 @@ export function chart(preset: ChartPreset): ChartBuilder {
2100
2101
  stat: "chart-stat" as const,
2101
2102
  waffle: "chart-waffle" as const,
2102
2103
  "stacked-bar": "chart-stacked-bar" as const,
2104
+ slope: "chart-slope" as const,
2103
2105
  };
2104
2106
 
2105
2107
  const api: ChartBuilder = {
@@ -15,6 +15,7 @@ import { ChartRadialNode } from "../kinds/chart-radial";
15
15
  import { ChartStatNode } from "../kinds/chart-stat";
16
16
  import { ChartWaffleNode } from "../kinds/chart-waffle";
17
17
  import { ChartStackedBarNode } from "../kinds/chart-stacked-bar";
18
+ import { ChartSlopeNode } from "../kinds/chart-slope";
18
19
  import { ChartBarNode } from "../kinds/chart-bar";
19
20
  import { GanttNode } from "../kinds/gantt";
20
21
  import { MindMapNode } from "../kinds/mind-map";
@@ -198,6 +199,17 @@ export function CdlNodeView({
198
199
  case "chart-stacked-bar":
199
200
  // 帯を割合で区切る。 前の時点を書けば 2 本になる (cdl#551)
200
201
  return <ChartStackedBarNode node={resolvedNode} active={active} stateValues={stateValues} />;
202
+ case "chart-slope":
203
+ // 2 時点を直線でつなぐ。 前の時点を書かない図は軸 1 本の点の並びになる (cdl#688)
204
+ return (
205
+ <ChartSlopeNode
206
+ node={resolvedNode}
207
+ active={active}
208
+ stateValues={stateValues}
209
+ drawing={drawing}
210
+ progress={progress}
211
+ />
212
+ );
201
213
  case "chart-waffle":
202
214
  // 1 個 = 1% の印を 100 個。 段の進みで印が埋まる動きは別 Issue (cdl#552 で対象外)
203
215
  return <ChartWaffleNode node={resolvedNode} active={active} stateValues={stateValues} />;
@@ -56,6 +56,8 @@ type 読まない欄 =
56
56
  | "chartFillUnder"
57
57
  | "chartValueRise"
58
58
  | "chartTrace"
59
+ // 円グラフの見せ方 (#684)。 決まった値のどれかで、字を差し込む先が無い
60
+ | "chartPieForm"
59
61
  | "ganttData"
60
62
  | "ganttAxisMax"
61
63
  | "mindData"
package/src/types.ts CHANGED
@@ -80,7 +80,8 @@ export const NODE_KINDS = [
80
80
  // chart-waffle は 100 個の印で割合を数えて確かめる形 (cdl#552)
81
81
  "chart-pie", "chart-line", "chart-bar", "chart-gauge", "chart-radial", "chart-stat",
82
82
  // chart-stacked-bar は帯を割合で区切り時点を並べる形 (cdl#551)
83
- "chart-waffle", "chart-stacked-bar",
83
+ // chart-slope は 2 時点を直線でつなぎ増減と順位の入れ替わりを見せる形 (cdl#688)
84
+ "chart-waffle", "chart-stacked-bar", "chart-slope",
84
85
  // Timeline / Mind 系 (2、 CAR-994 Phase B) ... 1 node に payload 配列、 SVG geometry render
85
86
  "gantt-timeline", "mind-map",
86
87
  // Analytic 系 (4、 CAR-994 Phase C) ... 1 node に payload 配列、 SVG geometry render
@@ -469,6 +470,18 @@ export type CdlNode = {
469
470
  chartValueRise?: boolean;
470
471
  /** chart-line を描き切った後、粒で経路をなぞって点を順に出す */
471
472
  chartTrace?: boolean;
473
+ /**
474
+ * chart-pie の見せ方 (cdl#684)。 書かない図は `ring` と同じ。
475
+ *
476
+ * - `ring` ... 輪を扇に切り分け、外へ引き出し線を伸ばして名札を置く
477
+ * - `arcs` ... 系列ごとに 1 本の弧を置き、割合を角度ではなく弧の長さで読む
478
+ * - `table` ... 輪を左に寄せ、右に 名前 / 割合 / 実数 の 3 列の表を組む
479
+ *
480
+ * 型の外の値は `ring` として描く = 見せ方が決まらないより、既定の形が出る方がよい。
481
+ *
482
+ * `previous` (cdl#680) が効くのは `ring` だけ。 他の形は内側の輪を描かない。
483
+ */
484
+ chartPieForm?: ChartPieForm;
472
485
  /**
473
486
  * gantt-timeline 専用の task payload。 task 全件を 1 node に格納し、
474
487
  * kinds/gantt.tsx が縦軸 task lane + 横軸日付 rect bar で SVG 描画する。
@@ -525,6 +538,9 @@ export type BoundNumber = number | string;
525
538
  export type BoundEnum<T extends string> = T | (string & {});
526
539
 
527
540
  /** Chart 系 kind の 1 datum payload。 label + value + optional tone (color) */
541
+ /** chart-pie の見せ方 (cdl#684)。 SSOT は `kinds/chart-pie.tsx` */
542
+ export type ChartPieForm = "ring" | "arcs" | "table";
543
+
528
544
  export type ChartDatumPayload = {
529
545
  label: string;
530
546
  /** 棒 / 折れ線 / 扇の大きさ。 `{signal}` で状態に追随する */
package/src/validate.ts CHANGED
@@ -17,6 +17,7 @@ const TONES = new Set<string>(TONE_NAMES);
17
17
  * | `chart-line` | 左端 | 線が右へ伸びる |
18
18
  * | `chart-bar` | 横軸 | 棒が上へ伸びる |
19
19
  * | `chart-pie` | 12 時 | 扇が時計回りに開く |
20
+ * | `chart-slope` | 左の軸 | 線が右の軸へ伸びる |
20
21
  * | `journey-map` | 左端 | 気持ちの線が右へ伸びる |
21
22
  * | `mind-map` | 中心 | 枝が中心から外へ伸びる |
22
23
  * | `tree-hierarchy` | 根 | 枝が上の段から下の段へ伸びる |
@@ -27,6 +28,7 @@ export const DRAW_KINDS: ReadonlySet<string> = new Set([
27
28
  "chart-line",
28
29
  "chart-bar",
29
30
  "chart-pie",
31
+ "chart-slope",
30
32
  "journey-map",
31
33
  "mind-map",
32
34
  "tree-hierarchy",
@@ -246,6 +246,7 @@ export const GRID_ALIGNED_KINDS: ReadonlySet<NodeKind> = new Set([
246
246
  "chart-stat",
247
247
  "chart-waffle",
248
248
  "chart-stacked-bar",
249
+ "chart-slope",
249
250
  "funnel-stages",
250
251
  "quadrant-matrix",
251
252
  ]);