@cardenelabs/cdl 0.14.0 → 0.15.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/CHANGELOG.md +223 -1
- package/README.md +20 -1
- package/dist/index.cjs +1233 -91
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -5
- package/dist/index.d.ts +21 -5
- package/dist/index.js +1232 -92
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +1208 -88
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1208 -88
- package/dist/react.js.map +1 -1
- package/dist/{render-B5JHcUrb.d.cts → render-BS1E6Mlo.d.cts} +55 -2
- package/dist/{render-B5JHcUrb.d.ts → render-BS1E6Mlo.d.ts} +55 -2
- package/package.json +1 -1
- package/src/builder.ts +24 -0
- package/src/index.ts +2 -1
- package/src/kinds/chart-gauge.tsx +241 -0
- package/src/kinds/chart-pie.tsx +214 -53
- package/src/kinds/chart-radial.tsx +210 -0
- package/src/kinds/chart-stacked-bar.tsx +272 -0
- package/src/kinds/chart-stat.tsx +231 -0
- package/src/kinds/chart-waffle.tsx +197 -0
- package/src/kinds/draw-ratio.ts +44 -0
- package/src/kinds/dyn-shape.tsx +3 -6
- package/src/kinds/generic.tsx +18 -6
- package/src/kinds/terminal-mark.tsx +96 -0
- package/src/layout/edges.ts +30 -1
- package/src/layout/self-loop.ts +217 -0
- package/src/layout/spec.ts +81 -4
- package/src/layout/tokens.ts +15 -0
- package/src/presets.ts +33 -3
- package/src/render/edge-head.ts +45 -0
- package/src/render/edges.tsx +4 -2
- package/src/render/interactive-panel.tsx +2 -2
- package/src/render/nodes.tsx +25 -0
- package/src/render/payload-binding.ts +14 -3
- package/src/render/stage.tsx +41 -18
- package/src/render/tone.ts +52 -0
- package/src/types.ts +66 -2
- package/src/validate.ts +13 -5
- package/src/visual-validate.ts +48 -1
package/src/kinds/chart-pie.tsx
CHANGED
|
@@ -1,17 +1,43 @@
|
|
|
1
1
|
import { useId, type JSX } from "react";
|
|
2
2
|
import type { LaidNode, Tone } from "../types";
|
|
3
|
-
import { TONE } from "../render/tone";
|
|
3
|
+
import { TONE, chartSeriesColor } from "../render/tone";
|
|
4
4
|
import { resolveChartData } from "../render/payload-binding";
|
|
5
5
|
import { 進みを畳む } from "./draw-progress";
|
|
6
|
+
import { textWidth } from "./text-width";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* chart-pie kind ... 1 node に datum 配列 (chartData) を保持し、 SVG path arc で円グラフ描画する。
|
|
9
|
-
*
|
|
10
|
+
*
|
|
11
|
+
* ## 幅は中身から配る (cdl#542)
|
|
12
|
+
*
|
|
13
|
+
* 以前は左半分が円、 右半分が凡例の固定だった。 凡例は名札と割合しか置かないので割り当ての
|
|
14
|
+
* 一部しか使わず、 **横長の枠では右が大きく空いたまま円が小さいまま** になっていた
|
|
15
|
+
* (実測 = 枠 929×465 で右の空白 369px = 幅の 39.7%、 円の面積比 24.9%)。
|
|
16
|
+
*
|
|
17
|
+
* 凡例の幅を `textWidth` で中身から見積もり、 残りを円に渡す。 円と凡例をまとめて枠の中央に
|
|
18
|
+
* 置くので、 枠が横長でも縦長でも余りが左右に均等に散る。
|
|
19
|
+
*
|
|
20
|
+
* ## 名札は扇のそばに置く (cdl#548)
|
|
21
|
+
*
|
|
22
|
+
* 凡例を右に置いていた間、 **扇と凡例で目線が往復** していた。 ある扇が何を指すかを知るために
|
|
23
|
+
* 凡例まで目を移し、 戻って次の扇を見ることになる。 実数はどこにも出ていなかった。
|
|
24
|
+
*
|
|
25
|
+
* 同じ repo の折れ線と並べて測ると差が出る = 折れ線は 目盛 / 補助線 / 実値 / 印の意匠 /
|
|
26
|
+
* 桁区切り の 5 層を持つのに対し、 円は塗り分けた塊と凡例しか持たない。
|
|
27
|
+
*
|
|
28
|
+
* **扇の外へ引き出し線を伸ばし、 名前と実数と割合をその場に置く**。 折れ線が各点に値を
|
|
29
|
+
* 書いているのと同じ考え方で、 凡例は無くなる。
|
|
30
|
+
*
|
|
31
|
+
* ## 中央を抜いて合計を置く (cdl#548)
|
|
32
|
+
*
|
|
33
|
+
* 折れ線の縦軸が全体の尺度を示しているのに対応する。 円の中央は元から何も置けない場所で、
|
|
34
|
+
* そこに合計が入ると「部分の合計は全体」 が図の中で閉じる。
|
|
35
|
+
*
|
|
10
36
|
* 各 value は数でも `{signal}` でもよく、 後者は状態が動くたび扇の角度が追随する。
|
|
11
37
|
*
|
|
12
38
|
* phase の `draw` にこの node の id が載っている間は、 円が 12 時から時計回りに開く (cdl#516)。
|
|
13
|
-
* 開き方は扇形の切り抜き (`clipPath`) で、 各扇の `d`
|
|
14
|
-
*
|
|
39
|
+
* 開き方は扇形の切り抜き (`clipPath`) で、 各扇の `d` は **進みに関わらず実値のまま**。
|
|
40
|
+
* 名札はその扇が開き始めた時に出る。
|
|
15
41
|
*/
|
|
16
42
|
export function ChartPieNode({
|
|
17
43
|
node,
|
|
@@ -38,10 +64,62 @@ export function ChartPieNode({
|
|
|
38
64
|
|
|
39
65
|
const PAD_TOP = 20;
|
|
40
66
|
const PAD_BOTTOM = 20;
|
|
67
|
+
/** 枠の内側に残す左右の余白 */
|
|
68
|
+
const PAD_SIDE = 20;
|
|
41
69
|
const chartAreaH = node.h - PAD_TOP - PAD_BOTTOM;
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const
|
|
70
|
+
|
|
71
|
+
const 名札の級 = 14;
|
|
72
|
+
const 数値の級 = 13;
|
|
73
|
+
|
|
74
|
+
/** 円の縁から引き出し線が折れるまで */
|
|
75
|
+
const 引き出し = 18;
|
|
76
|
+
/** 折れてから字が始まるまで */
|
|
77
|
+
const 字までの間 = 26;
|
|
78
|
+
/**
|
|
79
|
+
* 名札が折れ点から縦に張り出す量。
|
|
80
|
+
*
|
|
81
|
+
* 名前は `折れy - 3` の位置に級 14 で置くので上へ約 17、数値は `折れy + 15` に級 13 で
|
|
82
|
+
* 置くので下へ約 19 伸びる。 大きい方を採る = 上下どちらに出ても収まる。
|
|
83
|
+
*/
|
|
84
|
+
const 名札の縦の張り出し = 20;
|
|
85
|
+
|
|
86
|
+
const 割合の文字 = data.map((d) => 割合表記(d.value / total));
|
|
87
|
+
const 数値の文字 = data.map((d, i) => `${実値表記(d.value)} ${割合の文字[i]}`);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 名札 1 つが横に使う幅。 **中身から決める** = 割り当てを固定すると使わない分が死ぬ
|
|
91
|
+
* (cdl#542 で凡例について踏んだのと同じ)。
|
|
92
|
+
*
|
|
93
|
+
* 名前の行と数値の行のうち長い方を採る。 短い方に合わせると長い行が枠から出る。
|
|
94
|
+
*/
|
|
95
|
+
const 名札幅 = Math.max(
|
|
96
|
+
0,
|
|
97
|
+
...data.map((d, i) =>
|
|
98
|
+
Math.max(textWidth(d.label ?? "", 名札の級), textWidth(数値の文字[i] ?? "", 数値の級)),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 円の半径。 **名札が枠に収まる上限** を縦と横それぞれから出し、小さい方を採る。
|
|
104
|
+
*
|
|
105
|
+
* 名札は扇の向きに応じて円の四方へ出るので、どちらの向きに出ても収まる必要がある。
|
|
106
|
+
* 縦は `引き出し + 字の張り出し`、横は `引き出し + 字までの間 + 名札幅` を半径の外に残す。
|
|
107
|
+
*
|
|
108
|
+
* **#542 の時より円は小さくなる**。 あちらは名札が右にしか出なかったので、上下と左に
|
|
109
|
+
* 場所を取る必要が無かった。 その代わり凡例まで目を移す必要があり、実数も出ていない。
|
|
110
|
+
* 円の大きさと引き換えに情報の層を増やす判断で、円は依然として枠の高さの 8 割を使う。
|
|
111
|
+
*
|
|
112
|
+
* 残りが負や極小になる形 (枠が細い / 名前が長い) では円が消えるため下限を置き、潰れるより
|
|
113
|
+
* 名札が枠から少しはみ出す側に倒す = 円が消えると図として意味を失う。
|
|
114
|
+
*/
|
|
115
|
+
const 縦から = chartAreaH / 2 - 引き出し - 名札の縦の張り出し;
|
|
116
|
+
const 横から = (node.w - PAD_SIDE * 2) / 2 - 引き出し - 字までの間 - 名札幅;
|
|
117
|
+
const radius = Math.max(24, Math.min(縦から, 横から));
|
|
118
|
+
/** 中央を抜く半径。 合計を置ける広さを残しつつ、輪が細くなりすぎない比 */
|
|
119
|
+
const 内半径 = radius * 0.58;
|
|
120
|
+
|
|
121
|
+
/** 円は枠の中央に置く。 名札が左右へ均等に出る */
|
|
122
|
+
const cx = node.w / 2;
|
|
45
123
|
const cy = PAD_TOP + chartAreaH / 2;
|
|
46
124
|
|
|
47
125
|
let cumAngle = -Math.PI / 2;
|
|
@@ -60,21 +138,28 @@ export function ChartPieNode({
|
|
|
60
138
|
const y1 = cy + radius * Math.sin(a0);
|
|
61
139
|
const x2 = cx + radius * Math.cos(a1);
|
|
62
140
|
const y2 = cy + radius * Math.sin(a1);
|
|
141
|
+
const x3 = cx + 内半径 * Math.cos(a1);
|
|
142
|
+
const y3 = cy + 内半径 * Math.sin(a1);
|
|
143
|
+
const x4 = cx + 内半径 * Math.cos(a0);
|
|
144
|
+
const y4 = cy + 内半径 * Math.sin(a0);
|
|
63
145
|
const large = angle > Math.PI ? 1 : 0;
|
|
64
|
-
|
|
65
|
-
|
|
146
|
+
// 外周を時計回りに描き、内周を逆向きに戻って閉じる = 中央が抜けた輪になる
|
|
147
|
+
const path =
|
|
148
|
+
`M ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2} ` +
|
|
149
|
+
`L ${x3} ${y3} A ${内半径} ${内半径} 0 ${large} 0 ${x4} ${y4} Z`;
|
|
150
|
+
/** 引き出し線の起点。 扇の真ん中の向き */
|
|
151
|
+
const 中央角 = (a0 + a1) / 2;
|
|
152
|
+
return { d, frac, path, startFrac, 中央角 };
|
|
66
153
|
});
|
|
67
154
|
|
|
68
155
|
/** 円がどこまで開いたか (0..1、 cdl#516)。 畳み方は `draw-progress.ts` が持つ */
|
|
69
156
|
const 開き = 進みを畳む(drawing, progress);
|
|
70
157
|
// SVG の id は document 全体の名前空間。 node id ではなく instance id で分ける
|
|
71
158
|
const 切り抜きid = `cdl-pie-draw-${instanceId}`;
|
|
159
|
+
const 影id = `cdl-pie-shadow-${instanceId}`;
|
|
72
160
|
/** その扇が開き始めたか。 開き始めた扇の凡例だけを出す */
|
|
73
161
|
const 開き始めた = (startFrac: number): boolean => !drawing || 開き > startFrac;
|
|
74
162
|
|
|
75
|
-
const legendX = pieAreaW + 8;
|
|
76
|
-
const legendGap = Math.min(28, chartAreaH / Math.max(data.length, 1));
|
|
77
|
-
const legendStartY = PAD_TOP + (chartAreaH - legendGap * data.length) / 2 + legendGap / 2;
|
|
78
163
|
|
|
79
164
|
return (
|
|
80
165
|
<g transform={`translate(${x0} ${y0})`}>
|
|
@@ -89,18 +174,22 @@ export function ChartPieNode({
|
|
|
89
174
|
stroke={active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)"}
|
|
90
175
|
strokeWidth={active ? 3 : 1.25}
|
|
91
176
|
/>
|
|
92
|
-
|
|
93
|
-
|
|
177
|
+
<defs>
|
|
178
|
+
{drawing && (
|
|
94
179
|
<clipPath id={切り抜きid}>{扇形の切り抜き(cx, cy, radius + 2, 開き)}</clipPath>
|
|
95
|
-
|
|
96
|
-
|
|
180
|
+
)}
|
|
181
|
+
{/* 輪を地から浮かせる。 塗りだけだと図が紙に印刷されたように平坦に見える */}
|
|
182
|
+
<filter id={影id} x="-20%" y="-20%" width="140%" height="140%">
|
|
183
|
+
<feDropShadow dx="0" dy="6" stdDeviation="10" floodColor="#0b1220" floodOpacity="0.16" />
|
|
184
|
+
</filter>
|
|
185
|
+
</defs>
|
|
97
186
|
{/*
|
|
98
187
|
**扇の `d` は触らない** (cdl#516)。 進みで再計算すると、扇の形から取り分を測る
|
|
99
188
|
利用側が進みの途中で別の値を読む。 見える範囲だけを切り抜きで変える。
|
|
100
189
|
|
|
101
190
|
描く指定が無い図には包みも `clip-path` も足さない = 既存の図の DOM を変えない
|
|
102
191
|
*/}
|
|
103
|
-
<g {...(drawing ? { clipPath: `url(#${切り抜きid})` } : {})}>
|
|
192
|
+
<g {...(drawing ? { clipPath: `url(#${切り抜きid})` } : {})} filter={`url(#${影id})`}>
|
|
104
193
|
{slices.map((s, idx) => (
|
|
105
194
|
<path
|
|
106
195
|
key={`slice-${idx}`}
|
|
@@ -108,48 +197,87 @@ export function ChartPieNode({
|
|
|
108
197
|
data-cdl-unresolved={s.d.unresolved ? "true" : undefined}
|
|
109
198
|
d={s.path}
|
|
110
199
|
fill={sliceColor(s.d.tone, idx)}
|
|
111
|
-
|
|
200
|
+
/*
|
|
201
|
+
**半透明にしない** (cdl#548)。 地の色が透けて系列の色が濁る = 選んだ色と
|
|
202
|
+
画面に出る色が違う。 強調は色そのもので付ける。
|
|
203
|
+
|
|
204
|
+
区切りは線ではなく **地の色の余白** で取る。 線だと扇どうしが分断されて
|
|
205
|
+
見え、輪が 1 つの量に見えなくなる
|
|
206
|
+
*/
|
|
112
207
|
stroke="var(--cdl-node-fill, #ffffff)"
|
|
113
|
-
strokeWidth={
|
|
208
|
+
strokeWidth={3}
|
|
209
|
+
strokeLinejoin="round"
|
|
114
210
|
/>
|
|
115
211
|
))}
|
|
116
212
|
</g>
|
|
117
213
|
|
|
214
|
+
{/* 中央の合計。 部分の合計は全体、 が図の中で閉じる (cdl#548) */}
|
|
215
|
+
<text
|
|
216
|
+
x={cx}
|
|
217
|
+
y={cy - 2}
|
|
218
|
+
textAnchor="middle"
|
|
219
|
+
fontSize={Math.round(radius * 0.26)}
|
|
220
|
+
fontWeight={600}
|
|
221
|
+
fill="var(--cdl-text, #1a1f2a)"
|
|
222
|
+
>
|
|
223
|
+
{実値表記(Math.round(total))}
|
|
224
|
+
</text>
|
|
225
|
+
<text
|
|
226
|
+
x={cx}
|
|
227
|
+
y={cy + Math.round(radius * 0.2)}
|
|
228
|
+
textAnchor="middle"
|
|
229
|
+
fontSize={Math.round(radius * 0.12)}
|
|
230
|
+
fill="var(--cdl-text-dim, #5a6270)"
|
|
231
|
+
>
|
|
232
|
+
合計
|
|
233
|
+
</text>
|
|
234
|
+
|
|
118
235
|
{/*
|
|
119
|
-
|
|
120
|
-
`filter` で抜いても位置は同じになる (変異試験で実測 = 差が出ない)。
|
|
236
|
+
扇のそばの名札 (cdl#548)。 開き始めた扇だけを出す。
|
|
121
237
|
|
|
122
|
-
|
|
123
|
-
|
|
238
|
+
引き出し線は 円の縁 → 外へ真っすぐ → 字の手前で水平 の 3 点で折る。 水平で終える
|
|
239
|
+
のは、字の並びが扇の向きに引きずられないため = 傾いた線の先に字を置くと行が揃わない。
|
|
124
240
|
*/}
|
|
125
|
-
{slices.map((s, idx) =>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
{
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
241
|
+
{slices.map((s, idx) => {
|
|
242
|
+
if (!開き始めた(s.startFrac)) return null;
|
|
243
|
+
const 右 = Math.cos(s.中央角) >= 0;
|
|
244
|
+
const 縁x = cx + radius * Math.cos(s.中央角);
|
|
245
|
+
const 縁y = cy + radius * Math.sin(s.中央角);
|
|
246
|
+
const 折れx = cx + (radius + 引き出し) * Math.cos(s.中央角);
|
|
247
|
+
const 折れy = cy + (radius + 引き出し) * Math.sin(s.中央角);
|
|
248
|
+
const 字x = 折れx + (右 ? 字までの間 : -字までの間);
|
|
249
|
+
return (
|
|
250
|
+
<g key={`callout-${idx}`}>
|
|
251
|
+
<path
|
|
252
|
+
d={`M ${縁x} ${縁y} L ${折れx} ${折れy} L ${字x - (右 ? 6 : -6)} ${折れy}`}
|
|
253
|
+
fill="none"
|
|
254
|
+
stroke="var(--cdl-divider, #d4d4d8)"
|
|
255
|
+
strokeWidth={1}
|
|
256
|
+
/>
|
|
257
|
+
{/* 起点の点。 どの扇から伸びた線かを色で示す */}
|
|
258
|
+
<circle cx={縁x} cy={縁y} r={3} fill={sliceColor(s.d.tone, idx)} />
|
|
259
|
+
<text
|
|
260
|
+
x={字x}
|
|
261
|
+
y={折れy - 3}
|
|
262
|
+
textAnchor={右 ? "start" : "end"}
|
|
263
|
+
fontSize={名札の級}
|
|
264
|
+
fontWeight={600}
|
|
265
|
+
fill="var(--cdl-text, #1a1f2a)"
|
|
266
|
+
>
|
|
267
|
+
{s.d.label}
|
|
268
|
+
</text>
|
|
269
|
+
<text
|
|
270
|
+
x={字x}
|
|
271
|
+
y={折れy + 15}
|
|
272
|
+
textAnchor={右 ? "start" : "end"}
|
|
273
|
+
fontSize={数値の級}
|
|
274
|
+
fill="var(--cdl-text-dim, #5a6270)"
|
|
275
|
+
>
|
|
276
|
+
{数値の文字[idx]}
|
|
277
|
+
</text>
|
|
278
|
+
</g>
|
|
279
|
+
);
|
|
280
|
+
})}
|
|
153
281
|
</g>
|
|
154
282
|
);
|
|
155
283
|
}
|
|
@@ -171,9 +299,42 @@ function 扇形の切り抜き(cx: number, cy: number, r: number, 開き: number
|
|
|
171
299
|
return <path d={d} />;
|
|
172
300
|
}
|
|
173
301
|
|
|
174
|
-
|
|
302
|
+
/**
|
|
303
|
+
* 実値の表記 (cdl#548)。
|
|
304
|
+
*
|
|
305
|
+
* **桁区切りを入れる**。 同じ repo の折れ線が各点の値を `1,342` の形で置いており、そちらと
|
|
306
|
+
* 揃える。 4 桁を超えると区切りの有無で読む速さが変わる。
|
|
307
|
+
*
|
|
308
|
+
* 小数は 1 桁までに丸める。 図に置く数なので、桁を尽くすより並びが揃う方が読みやすい。
|
|
309
|
+
*/
|
|
310
|
+
function 実値表記(v: number): string {
|
|
311
|
+
return Number.isInteger(v)
|
|
312
|
+
? v.toLocaleString("en-US")
|
|
313
|
+
: v.toLocaleString("en-US", { maximumFractionDigits: 1 });
|
|
314
|
+
}
|
|
175
315
|
|
|
316
|
+
/**
|
|
317
|
+
* 取り分の表記 (cdl#542)。
|
|
318
|
+
*
|
|
319
|
+
* **整数のとき小数を出さない**。 `toFixed(1)` 固定だと 3 件とも `45.0%` のように無駄な
|
|
320
|
+
* `.0` が付き、桁が増えて読みにくい。 小数を持つ時だけ 1 桁出す。
|
|
321
|
+
*
|
|
322
|
+
* 丸めた結果が整数になる形 (`0.4499` → `45.0`) も整数として出す = 丸めた後の値で判定する。
|
|
323
|
+
*/
|
|
324
|
+
function 割合表記(frac: number): string {
|
|
325
|
+
const v = Math.round(frac * 1000) / 10;
|
|
326
|
+
return `${Number.isInteger(v) ? v : v.toFixed(1)}%`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 扇の色 (cdl#546)。
|
|
331
|
+
*
|
|
332
|
+
* 色を明示しない datum には **図表の系列専用の色** (`CHART_SERIES`) を順に配る。
|
|
333
|
+
* 以前は意味付きの色 (`TONE`) を使い回しており、並べて見分ける前提で選ばれていない色が
|
|
334
|
+
* 混ざっていた (経緯は `render/tone.ts`)。
|
|
335
|
+
*/
|
|
176
336
|
function sliceColor(tone: Tone | undefined, idx: number): string {
|
|
337
|
+
// 明示した色が優先される = 「この扇は異常」 を示したい図はそのまま書ける
|
|
177
338
|
if (tone) return TONE[tone];
|
|
178
|
-
return
|
|
339
|
+
return chartSeriesColor(idx);
|
|
179
340
|
}
|
|
@@ -0,0 +1,210 @@
|
|
|
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
|
+
|
|
7
|
+
/**
|
|
8
|
+
* chart-radial kind ... 1 件を 1 本の弧にして内側から重ね、 長さで比べる図 (cdl#550)。
|
|
9
|
+
*
|
|
10
|
+
* ## 何のための種別か
|
|
11
|
+
*
|
|
12
|
+
* 割合を示す図は `chart-pie` だけで、 扇の **角度** で比べる形になる。 角度の目測は長さの
|
|
13
|
+
* 目測より外れやすく、 40% と 45% のような近い値は見分けが付かない。
|
|
14
|
+
*
|
|
15
|
+
* `chart-bar` は長さで比べられるが、 **全体に対する割合** という感覚を持たない。 棒の長さは
|
|
16
|
+
* 最大値に対する比率で、 100% がどこかを示さない。
|
|
17
|
+
*
|
|
18
|
+
* ## 全周を 100% とする
|
|
19
|
+
*
|
|
20
|
+
* 各弧の背後に全周の軌道を敷き、 その上に取り分ぶんの弧を重ねる。 軌道があることで
|
|
21
|
+
* 「あとどれだけで一周か」 が見え、 棒グラフに無い「全体に対する割合」 が出る。
|
|
22
|
+
*
|
|
23
|
+
* 弧は 12 時から時計回りに伸びる。 円グラフと起点を揃えている。
|
|
24
|
+
*
|
|
25
|
+
* ## 名前は左に縦へ揃える
|
|
26
|
+
*
|
|
27
|
+
* 弧のそばに置くと、 内側の弧ほど場所が狭くなって字が入らない。 左端に縦へ並べると件数が
|
|
28
|
+
* 増えても一覧性が落ちず、 弧の並び順と行の並び順が対応する。
|
|
29
|
+
*/
|
|
30
|
+
export function ChartRadialNode({
|
|
31
|
+
node,
|
|
32
|
+
active,
|
|
33
|
+
stateValues = {},
|
|
34
|
+
}: {
|
|
35
|
+
node: LaidNode;
|
|
36
|
+
active: boolean;
|
|
37
|
+
stateValues?: Record<string, string>;
|
|
38
|
+
}): JSX.Element {
|
|
39
|
+
const x0 = node.cx - node.w / 2;
|
|
40
|
+
const y0 = node.cy - node.h / 2;
|
|
41
|
+
const data = resolveChartData(node.chartData ?? [], stateValues);
|
|
42
|
+
const total = data.reduce((acc, d) => acc + d.value, 0) || 1;
|
|
43
|
+
|
|
44
|
+
const PAD = 20;
|
|
45
|
+
const 使える高さ = node.h - PAD * 2;
|
|
46
|
+
const 名前の級 = 14;
|
|
47
|
+
const 数値の級 = 13;
|
|
48
|
+
/** 一覧の 1 行が縦に使う高さ */
|
|
49
|
+
const 行の高さ = 42;
|
|
50
|
+
/** 一覧と弧の間 */
|
|
51
|
+
const 一覧と弧の間 = 28;
|
|
52
|
+
|
|
53
|
+
const 数値の文字 = data.map((d) => `${実値表記(d.value)} ${割合表記(d.value / total)}`);
|
|
54
|
+
/** 一覧の幅。 **中身から決める** = 割り当てを固定すると使わない分が死ぬ (cdl#542) */
|
|
55
|
+
const 一覧の幅 =
|
|
56
|
+
印の幅 +
|
|
57
|
+
Math.max(
|
|
58
|
+
0,
|
|
59
|
+
...data.map((d, i) =>
|
|
60
|
+
Math.max(textWidth(d.label ?? "", 名前の級), textWidth(数値の文字[i] ?? "", 数値の級)),
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* いちばん外の弧の半径。
|
|
66
|
+
*
|
|
67
|
+
* 弧は内側へ `帯の幅 + 帯の間` ずつ縮んで重なるので、 件数ぶんの厚みが要る。 高さと、
|
|
68
|
+
* 一覧を引いた残り幅の小さい方に収める。
|
|
69
|
+
*/
|
|
70
|
+
const 弧に使える幅 = node.w - PAD * 2 - 一覧の幅 - 一覧と弧の間;
|
|
71
|
+
const 外半径 = Math.max(24, Math.min(使える高さ / 2, 弧に使える幅 / 2));
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 帯 1 本の太さと間。
|
|
75
|
+
*
|
|
76
|
+
* **件数から決める**。 太さを固定すると、 件数が増えた時に内側の弧が中心を突き抜ける。
|
|
77
|
+
* いちばん内側の弧が半径の 3 割を残す形で割り付ける。
|
|
78
|
+
*/
|
|
79
|
+
const 使える厚み = 外半径 * 0.7;
|
|
80
|
+
const 帯1本 = 使える厚み / Math.max(data.length, 1);
|
|
81
|
+
const 帯の幅 = 帯1本 * 0.66;
|
|
82
|
+
|
|
83
|
+
const 弧のcx = PAD + 一覧の幅 + 一覧と弧の間 + 外半径;
|
|
84
|
+
const cy = PAD + 使える高さ / 2;
|
|
85
|
+
|
|
86
|
+
const rings = data.map((d, idx) => {
|
|
87
|
+
const frac = d.value / total;
|
|
88
|
+
const 中心半径 = 外半径 - 帯1本 * idx - 帯1本 / 2;
|
|
89
|
+
return {
|
|
90
|
+
d,
|
|
91
|
+
frac,
|
|
92
|
+
中心半径,
|
|
93
|
+
// 軌道は全周。 1 周ちょうどは弧で表せないので、 わずかに欠かして描く
|
|
94
|
+
軌道: 弧の道(弧のcx, cy, 中心半径, 帯の幅, 0.9999),
|
|
95
|
+
取り分: frac <= 0 ? null : 弧の道(弧のcx, cy, 中心半径, 帯の幅, Math.min(frac, 0.9999)),
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const 一覧の始まり = PAD + (使える高さ - 行の高さ * data.length) / 2 + 行の高さ / 2;
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<g transform={`translate(${x0} ${y0})`}>
|
|
103
|
+
<rect
|
|
104
|
+
data-cdl-role="node-body"
|
|
105
|
+
x={0}
|
|
106
|
+
y={0}
|
|
107
|
+
width={node.w}
|
|
108
|
+
height={node.h}
|
|
109
|
+
rx={16}
|
|
110
|
+
fill="var(--cdl-node-fill, #ffffff)"
|
|
111
|
+
stroke={active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)"}
|
|
112
|
+
strokeWidth={active ? 3 : 1.25}
|
|
113
|
+
/>
|
|
114
|
+
|
|
115
|
+
{/*
|
|
116
|
+
軌道。 **全周を敷く** = これが無いと「あとどれだけで一周か」 が読めず、 棒グラフと
|
|
117
|
+
同じ「最大値に対する比率」 の図になる。 この種別の存在理由に直結する。
|
|
118
|
+
*/}
|
|
119
|
+
{rings.map((r, idx) => (
|
|
120
|
+
<path
|
|
121
|
+
key={`track-${idx}`}
|
|
122
|
+
data-cdl-role="chart-radial-track"
|
|
123
|
+
d={r.軌道}
|
|
124
|
+
fill="var(--cdl-divider, #d4d4d8)"
|
|
125
|
+
fillOpacity={0.28}
|
|
126
|
+
/>
|
|
127
|
+
))}
|
|
128
|
+
|
|
129
|
+
{rings.map((r, idx) =>
|
|
130
|
+
r.取り分 === null ? null : (
|
|
131
|
+
<path
|
|
132
|
+
key={`arc-${idx}`}
|
|
133
|
+
data-cdl-role="chart-radial-arc"
|
|
134
|
+
data-cdl-unresolved={r.d.unresolved ? "true" : undefined}
|
|
135
|
+
d={r.取り分}
|
|
136
|
+
fill={arcColor(r.d.tone, idx)}
|
|
137
|
+
/>
|
|
138
|
+
),
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{/* 一覧。 行の並びが弧の並び (外から内へ) と対応する */}
|
|
142
|
+
{data.map((d, idx) => {
|
|
143
|
+
const y = 一覧の始まり + 行の高さ * idx;
|
|
144
|
+
return (
|
|
145
|
+
<g key={`item-${idx}`} data-cdl-role="chart-radial-item">
|
|
146
|
+
<rect x={PAD} y={y - 11} width={4} height={22} rx={2} fill={arcColor(d.tone, idx)} />
|
|
147
|
+
<text
|
|
148
|
+
x={PAD + 印の幅}
|
|
149
|
+
y={y + 1}
|
|
150
|
+
fontSize={名前の級}
|
|
151
|
+
fontWeight={700}
|
|
152
|
+
fill="var(--cdl-text, #1a1f2a)"
|
|
153
|
+
>
|
|
154
|
+
{d.label}
|
|
155
|
+
</text>
|
|
156
|
+
<text
|
|
157
|
+
x={PAD + 印の幅}
|
|
158
|
+
y={y + 20}
|
|
159
|
+
fontSize={数値の級}
|
|
160
|
+
fill="var(--cdl-text-dim, #5a6270)"
|
|
161
|
+
>
|
|
162
|
+
{数値の文字[idx]}
|
|
163
|
+
</text>
|
|
164
|
+
</g>
|
|
165
|
+
);
|
|
166
|
+
})}
|
|
167
|
+
</g>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 一覧の色の印と、字が始まるまでの幅 */
|
|
172
|
+
const 印の幅 = 18;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 12 時から時計回りに `割合` ぶん伸びる帯の道。
|
|
176
|
+
*
|
|
177
|
+
* 外周を時計回りに描き、 内周を逆向きに戻って閉じる。 **1 周ちょうどは扇形で表せない**
|
|
178
|
+
* (始点と終点が同じ点になり弧の向きが決まらない) ため、 呼出側でわずかに欠かして渡す。
|
|
179
|
+
*/
|
|
180
|
+
function 弧の道(cx: number, cy: number, 中心半径: number, 幅: number, 割合: number): string {
|
|
181
|
+
const 外 = 中心半径 + 幅 / 2;
|
|
182
|
+
const 内 = Math.max(0.5, 中心半径 - 幅 / 2);
|
|
183
|
+
const a0 = -Math.PI / 2;
|
|
184
|
+
const a1 = a0 + 割合 * Math.PI * 2;
|
|
185
|
+
const large = 割合 > 0.5 ? 1 : 0;
|
|
186
|
+
const p = (r: number, a: number) => `${cx + r * Math.cos(a)} ${cy + r * Math.sin(a)}`;
|
|
187
|
+
return (
|
|
188
|
+
`M ${p(外, a0)} A ${外} ${外} 0 ${large} 1 ${p(外, a1)} ` +
|
|
189
|
+
`L ${p(内, a1)} A ${内} ${内} 0 ${large} 0 ${p(内, a0)} Z`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 実値の表記。 桁区切りを入れる (`chart-pie` と揃える) */
|
|
194
|
+
function 実値表記(v: number): string {
|
|
195
|
+
return Number.isInteger(v)
|
|
196
|
+
? v.toLocaleString("en-US")
|
|
197
|
+
: v.toLocaleString("en-US", { maximumFractionDigits: 1 });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 取り分の表記。 整数のとき小数を出さない (`chart-pie` と揃える) */
|
|
201
|
+
function 割合表記(frac: number): string {
|
|
202
|
+
const v = Math.round(frac * 1000) / 10;
|
|
203
|
+
return `${Number.isInteger(v) ? v : v.toFixed(1)}%`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 弧の色。 明示した色が優先され、 無ければ図表の系列の色を順に配る */
|
|
207
|
+
function arcColor(tone: Tone | undefined, idx: number): string {
|
|
208
|
+
if (tone) return TONE[tone];
|
|
209
|
+
return chartSeriesColor(idx);
|
|
210
|
+
}
|