@cardenelabs/cdl 0.14.0 → 0.16.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 +249 -1
- package/README.md +20 -1
- package/dist/index.cjs +1243 -92
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -5
- package/dist/index.d.ts +27 -5
- package/dist/index.js +1242 -93
- 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 +68 -4
- 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
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { type JSX } from "react";
|
|
2
|
+
import type { LaidNode, Tone } from "../types";
|
|
3
|
+
import { TONE, chartSeriesColor } from "../render/tone";
|
|
4
|
+
import { resolveChartData, type ResolvedChartDatum } from "../render/payload-binding";
|
|
5
|
+
import { textWidth } from "./text-width";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* chart-stacked-bar kind ... 1 本の帯を割合で区切り、時点を並べて変化を見せる図 (cdl#551)。
|
|
9
|
+
*
|
|
10
|
+
* ## 何のための種別か
|
|
11
|
+
*
|
|
12
|
+
* `chart-pie` は 1 時点の内訳しか描けない。 段が進んで値が動くと前の姿は消えるので、
|
|
13
|
+
* 「昨年と今年で構成比がどう変わったか」 は読み手の記憶に頼ることになる。
|
|
14
|
+
* 扇の角度は近い値の比較にも弱い (45% と 39% は目測で見分けが付かない)。
|
|
15
|
+
*
|
|
16
|
+
* 帯を割合で区切れば **長さで比べられる**。 帯を縦に並べれば時点間の変化がそのまま見える。
|
|
17
|
+
*
|
|
18
|
+
* ## 前の時点は datum の `previous` で持つ
|
|
19
|
+
*
|
|
20
|
+
* 節を時点ごとに置く案は採らない = 図表は「1 節に datum 配列を持ち、種別が全 datum を描く」
|
|
21
|
+
* 構造で揃っており、節を増やすと配置と組み立て器の両方が別経路になる。
|
|
22
|
+
*
|
|
23
|
+
* 1 件でも `previous` を持てば帯が 2 本になる。 持たない図は今までどおり 1 本。
|
|
24
|
+
*
|
|
25
|
+
* ## 区画の字は入る時だけ描く
|
|
26
|
+
*
|
|
27
|
+
* 細い区画に字を詰めると隣の区画へはみ出して、どちらの値か読めなくなる。
|
|
28
|
+
* 入らない区画は字を描かず、名前は下の一覧が受け持つ。
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** 区画に割合を描くために要る幅。 これ未満の区画は字を描かない */
|
|
32
|
+
export const SLICE_MIN_W_FOR_TEXT = 44;
|
|
33
|
+
|
|
34
|
+
/** 区画に実数も描くために要る幅 (割合の分より広い) */
|
|
35
|
+
export const SLICE_MIN_W_FOR_VALUE = 84;
|
|
36
|
+
|
|
37
|
+
export function ChartStackedBarNode({
|
|
38
|
+
node,
|
|
39
|
+
active,
|
|
40
|
+
stateValues = {},
|
|
41
|
+
}: {
|
|
42
|
+
node: LaidNode;
|
|
43
|
+
active: boolean;
|
|
44
|
+
stateValues?: Record<string, string>;
|
|
45
|
+
}): JSX.Element {
|
|
46
|
+
const x0 = node.cx - node.w / 2;
|
|
47
|
+
const y0 = node.cy - node.h / 2;
|
|
48
|
+
const data = resolveChartData(node.chartData ?? [], stateValues);
|
|
49
|
+
|
|
50
|
+
const 帯たち = 帯の並び(data);
|
|
51
|
+
|
|
52
|
+
const PAD = 24;
|
|
53
|
+
const 名前の級 = 13;
|
|
54
|
+
const 区画の級 = 12;
|
|
55
|
+
const 時点の級 = 13;
|
|
56
|
+
/** 時点の名前を置く左の幅 */
|
|
57
|
+
const 時点の幅 =
|
|
58
|
+
Math.max(0, ...帯たち.map((b) => textWidth(b.名前, 時点の級))) + (帯たち.length > 1 ? 16 : 0);
|
|
59
|
+
const 帯の左 = PAD + 時点の幅;
|
|
60
|
+
const 帯の幅 = Math.max(40, node.w - 帯の左 - PAD);
|
|
61
|
+
const 帯の高さ = 44;
|
|
62
|
+
const 帯の間 = 20;
|
|
63
|
+
/** 下に置く一覧の 1 行 */
|
|
64
|
+
const 一覧の高さ = 26;
|
|
65
|
+
|
|
66
|
+
const 全体の高さ = 帯たち.length * 帯の高さ + (帯たち.length - 1) * 帯の間 + 16 + 一覧の高さ;
|
|
67
|
+
const 上端 = Math.max(PAD, (node.h - 全体の高さ) / 2);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<g transform={`translate(${x0} ${y0})`}>
|
|
71
|
+
<rect
|
|
72
|
+
data-cdl-role="node-body"
|
|
73
|
+
x={0}
|
|
74
|
+
y={0}
|
|
75
|
+
width={node.w}
|
|
76
|
+
height={node.h}
|
|
77
|
+
rx={16}
|
|
78
|
+
fill="var(--cdl-node-fill, #ffffff)"
|
|
79
|
+
stroke={active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)"}
|
|
80
|
+
strokeWidth={active ? 3 : 1.25}
|
|
81
|
+
/>
|
|
82
|
+
|
|
83
|
+
{帯たち.map((帯, 段) => {
|
|
84
|
+
const y = 上端 + 段 * (帯の高さ + 帯の間);
|
|
85
|
+
let 左 = 帯の左;
|
|
86
|
+
return (
|
|
87
|
+
<g key={`bar-${段}`} data-cdl-role="chart-stacked-bar-row">
|
|
88
|
+
{/* 時点の名前。 帯が 1 本だけの図では描かない (比べる相手がいない) */}
|
|
89
|
+
{帯たち.length > 1 && (
|
|
90
|
+
<text
|
|
91
|
+
data-cdl-role="chart-stacked-bar-period"
|
|
92
|
+
x={PAD}
|
|
93
|
+
y={y + 帯の高さ / 2 + 4}
|
|
94
|
+
fontSize={時点の級}
|
|
95
|
+
fontWeight={700}
|
|
96
|
+
fill="var(--cdl-text-dim, #5a6270)"
|
|
97
|
+
>
|
|
98
|
+
{帯.名前}
|
|
99
|
+
</text>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{帯.区画.map((s, idx) => {
|
|
103
|
+
const w = 帯の幅 * s.割合;
|
|
104
|
+
const x = 左;
|
|
105
|
+
左 += w;
|
|
106
|
+
const 色 = 印の色(s.tone, idx);
|
|
107
|
+
return (
|
|
108
|
+
<g key={`slice-${idx}`}>
|
|
109
|
+
<rect
|
|
110
|
+
data-cdl-role="chart-stacked-bar-slice"
|
|
111
|
+
data-cdl-unresolved={s.unresolved ? "true" : undefined}
|
|
112
|
+
x={x}
|
|
113
|
+
y={y}
|
|
114
|
+
width={Math.max(0, w)}
|
|
115
|
+
height={帯の高さ}
|
|
116
|
+
fill={色}
|
|
117
|
+
/>
|
|
118
|
+
{/*
|
|
119
|
+
区画の字。 **入る時だけ描く** = 細い区画に詰めると隣へはみ出して
|
|
120
|
+
どちらの値か読めなくなる。 名前は下の一覧が受け持つ
|
|
121
|
+
*/}
|
|
122
|
+
{w >= SLICE_MIN_W_FOR_TEXT && (
|
|
123
|
+
<text
|
|
124
|
+
data-cdl-role="chart-stacked-bar-slice-text"
|
|
125
|
+
x={x + w / 2}
|
|
126
|
+
y={y + 帯の高さ / 2 + (w >= SLICE_MIN_W_FOR_VALUE ? -2 : 4)}
|
|
127
|
+
fontSize={区画の級}
|
|
128
|
+
fontWeight={700}
|
|
129
|
+
textAnchor="middle"
|
|
130
|
+
fill="#ffffff"
|
|
131
|
+
>
|
|
132
|
+
{割合表記(s.割合)}
|
|
133
|
+
</text>
|
|
134
|
+
)}
|
|
135
|
+
{w >= SLICE_MIN_W_FOR_VALUE && (
|
|
136
|
+
<text
|
|
137
|
+
data-cdl-role="chart-stacked-bar-slice-value"
|
|
138
|
+
x={x + w / 2}
|
|
139
|
+
y={y + 帯の高さ / 2 + 13}
|
|
140
|
+
fontSize={区画の級 - 1}
|
|
141
|
+
textAnchor="middle"
|
|
142
|
+
fill="#ffffff"
|
|
143
|
+
fillOpacity={0.85}
|
|
144
|
+
>
|
|
145
|
+
{実値表記(s.実値)}
|
|
146
|
+
</text>
|
|
147
|
+
)}
|
|
148
|
+
</g>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
151
|
+
</g>
|
|
152
|
+
);
|
|
153
|
+
})}
|
|
154
|
+
|
|
155
|
+
{/* 一覧。 区画に字が入らなかった件もここで名前と色が引ける */}
|
|
156
|
+
{一覧の並び(data, 帯の左, node.w - PAD, 名前の級).map(({ d, idx, x, 行 }) => {
|
|
157
|
+
const y =
|
|
158
|
+
上端 + 帯たち.length * (帯の高さ + 帯の間) - 帯の間 + 16 + 一覧の高さ / 2 + 行 * 一覧の高さ;
|
|
159
|
+
return (
|
|
160
|
+
<g key={`legend-${idx}`} data-cdl-role="chart-stacked-bar-legend">
|
|
161
|
+
<rect x={x} y={y - 5} width={10} height={10} rx={2} fill={印の色(d.tone, idx)} />
|
|
162
|
+
<text
|
|
163
|
+
x={x + 16}
|
|
164
|
+
y={y + 4}
|
|
165
|
+
fontSize={名前の級}
|
|
166
|
+
fill="var(--cdl-text, #1a1f2a)"
|
|
167
|
+
>
|
|
168
|
+
{d.label}
|
|
169
|
+
</text>
|
|
170
|
+
</g>
|
|
171
|
+
);
|
|
172
|
+
})}
|
|
173
|
+
</g>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 1 本の帯 */
|
|
178
|
+
export type 帯 = {
|
|
179
|
+
名前: string;
|
|
180
|
+
区画: Array<{ 割合: number; 実値: number; tone?: Tone; unresolved?: true }>;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* datum を帯の並びに畳む。
|
|
185
|
+
*
|
|
186
|
+
* **1 件でも `previous` を持てば 2 本になる**。 持たない件はその時点で 0 として扱う =
|
|
187
|
+
* 件を落とすと帯ごとに区画の数が変わり、上下で色の並びが対応しなくなる。
|
|
188
|
+
*
|
|
189
|
+
* 並びは 前 → 今 の順。 上から下へ読むと時間が進む。
|
|
190
|
+
*/
|
|
191
|
+
export function 帯の並び(data: readonly ResolvedChartDatum[]): 帯[] {
|
|
192
|
+
const 前あり = data.some((d) => d.previous !== undefined);
|
|
193
|
+
const 今 = 帯を組む("今", data, (d) => d.value);
|
|
194
|
+
if (!前あり) return [今];
|
|
195
|
+
return [帯を組む("前", data, (d) => d.previous ?? 0), 今];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function 帯を組む(
|
|
199
|
+
名前: string,
|
|
200
|
+
data: readonly ResolvedChartDatum[],
|
|
201
|
+
取る: (d: ResolvedChartDatum) => number,
|
|
202
|
+
): 帯 {
|
|
203
|
+
const 値 = data.map((d) => Math.max(0, 取る(d)));
|
|
204
|
+
const 合計 = 値.reduce((a, v) => a + v, 0);
|
|
205
|
+
return {
|
|
206
|
+
名前,
|
|
207
|
+
区画: data.map((d, i) => ({
|
|
208
|
+
// 合計 0 の帯は区画を持たない (0 で割らない)。 幅 0 の区画として並べる
|
|
209
|
+
割合: 合計 > 0 ? 値[i]! / 合計 : 0,
|
|
210
|
+
実値: 値[i]!,
|
|
211
|
+
tone: d.tone,
|
|
212
|
+
...(d.unresolved ? { unresolved: true as const } : {}),
|
|
213
|
+
})),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 実値の表記。 桁区切りを入れる (他の図表と揃える) */
|
|
218
|
+
function 実値表記(v: number): string {
|
|
219
|
+
return Number.isInteger(v)
|
|
220
|
+
? v.toLocaleString("en-US")
|
|
221
|
+
: v.toLocaleString("en-US", { maximumFractionDigits: 1 });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 取り分の表記。 整数のとき小数を出さない (他の図表と揃える) */
|
|
225
|
+
function 割合表記(frac: number): string {
|
|
226
|
+
const v = Math.round(frac * 1000) / 10;
|
|
227
|
+
return `${Number.isInteger(v) ? v : v.toFixed(1)}%`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 区画の色。 明示した色が優先され、無ければ図表の系列の色を順に配る */
|
|
231
|
+
function 印の色(tone: Tone | undefined, idx: number): string {
|
|
232
|
+
if (tone) return TONE[tone];
|
|
233
|
+
return chartSeriesColor(idx);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 一覧の 1 件が横に使う幅 (色の印 + 間 + 名前) と、件どうしの間 */
|
|
237
|
+
const 印から名前まで = 16;
|
|
238
|
+
const 件の間 = 10;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 一覧の並び。 **枠に入らなくなったら行を変える** (#568)。
|
|
242
|
+
*
|
|
243
|
+
* 折り返さないと、名前が長い図を細い枠で描いた時に右へ流れて箱の外へ出る
|
|
244
|
+
* (実測で 260 幅の枠に 3 件を並べて 16 はみ出した)。
|
|
245
|
+
*
|
|
246
|
+
* 名前は切らない。 折り返しで収まるなら、切って読めなくするより行を増やす方がよい。
|
|
247
|
+
* 1 件だけで既に入らない場合は、その 1 件を置いてから次へ折り返す = 空の行を作らない。
|
|
248
|
+
*
|
|
249
|
+
* @param 左 一覧が始まる x
|
|
250
|
+
* @param 右端 これを超えたら折り返す x (箱の内側の右端)
|
|
251
|
+
*/
|
|
252
|
+
export function 一覧の並び(
|
|
253
|
+
data: readonly ResolvedChartDatum[],
|
|
254
|
+
左: number,
|
|
255
|
+
右端: number,
|
|
256
|
+
級: number,
|
|
257
|
+
): Array<{ d: ResolvedChartDatum; idx: number; x: number; 行: number }> {
|
|
258
|
+
const out: Array<{ d: ResolvedChartDatum; idx: number; x: number; 行: number }> = [];
|
|
259
|
+
let x = 左;
|
|
260
|
+
let 行 = 0;
|
|
261
|
+
data.forEach((d, idx) => {
|
|
262
|
+
const 幅 = 印から名前まで + textWidth(d.label ?? "", 級);
|
|
263
|
+
// 行の先頭では折り返さない = 折り返しても同じ位置に戻るだけで、空の行が増える
|
|
264
|
+
if (x > 左 && x + 幅 > 右端) {
|
|
265
|
+
行 += 1;
|
|
266
|
+
x = 左;
|
|
267
|
+
}
|
|
268
|
+
out.push({ d, idx, x, 行 });
|
|
269
|
+
x += 幅 + 件の間;
|
|
270
|
+
});
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
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-stat kind ... 数値そのものを主役にする図 (cdl#553)。
|
|
9
|
+
*
|
|
10
|
+
* ## 何のための種別か
|
|
11
|
+
*
|
|
12
|
+
* 既存の図表はどれも **形で比率を測る** 作りで、数値は名札や凡例に小さく添える。
|
|
13
|
+
* 「今月の売上は 4,500」 のように **数値を読ませたい** 場面では、形が主役だと数値が小さすぎる。
|
|
14
|
+
*
|
|
15
|
+
* `chart-bar` で 1 件だけ描く形も試せるが、棒が 1 本の図になり、比べる相手が無いのに軸と
|
|
16
|
+
* 目盛が出る。
|
|
17
|
+
*
|
|
18
|
+
* ## 数値をいちばん大きく組む
|
|
19
|
+
*
|
|
20
|
+
* 件ごとに区画を割り、名前 / 数値 / 割合を縦に積む。 **数値の字を名前より大きくする** のが
|
|
21
|
+
* この種別の中身で、大小が逆転したら別の図になる。
|
|
22
|
+
*
|
|
23
|
+
* ## 弧は輪郭だけの細い線にする
|
|
24
|
+
*
|
|
25
|
+
* 比率は補助として添える。 面で塗ると数値より目立ち、主役が入れ替わる。
|
|
26
|
+
* 弧は 12 時から時計回りに伸びる (`chart-pie` / `chart-radial` と起点を揃えている)。
|
|
27
|
+
*/
|
|
28
|
+
export function ChartStatNode({
|
|
29
|
+
node,
|
|
30
|
+
active,
|
|
31
|
+
stateValues = {},
|
|
32
|
+
}: {
|
|
33
|
+
node: LaidNode;
|
|
34
|
+
active: boolean;
|
|
35
|
+
stateValues?: Record<string, string>;
|
|
36
|
+
}): JSX.Element {
|
|
37
|
+
const x0 = node.cx - node.w / 2;
|
|
38
|
+
const y0 = node.cy - node.h / 2;
|
|
39
|
+
const data = resolveChartData(node.chartData ?? [], stateValues);
|
|
40
|
+
const total = data.reduce((acc, d) => acc + d.value, 0) || 1;
|
|
41
|
+
|
|
42
|
+
const 区画 = 区画の割付(node.w, node.h, data.length);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<g transform={`translate(${x0} ${y0})`}>
|
|
46
|
+
<rect
|
|
47
|
+
data-cdl-role="node-body"
|
|
48
|
+
x={0}
|
|
49
|
+
y={0}
|
|
50
|
+
width={node.w}
|
|
51
|
+
height={node.h}
|
|
52
|
+
rx={16}
|
|
53
|
+
fill="var(--cdl-node-fill, #ffffff)"
|
|
54
|
+
stroke={active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)"}
|
|
55
|
+
strokeWidth={active ? 3 : 1.25}
|
|
56
|
+
/>
|
|
57
|
+
|
|
58
|
+
{data.map((d, idx) => {
|
|
59
|
+
const 枠 = 区画.枠(idx);
|
|
60
|
+
const frac = total > 0 ? d.value / total : 0;
|
|
61
|
+
const 色 = 印の色(d.tone, idx);
|
|
62
|
+
const 数値 = 実値表記(d.value);
|
|
63
|
+
// 数値は区画に収まるまで小さくする。 収まらないまま描くと隣の区画へはみ出す
|
|
64
|
+
const 数値の級 = 収める級(数値, 枠.w - 区画.余白 * 2, 区画.数値の級, 区画.名前の級 + 1);
|
|
65
|
+
return (
|
|
66
|
+
<g key={`stat-${idx}`} data-cdl-role="chart-stat-item" transform={`translate(${枠.x} ${枠.y})`}>
|
|
67
|
+
{/* 仕切り。 1 件だけの図では引かない (仕切る相手がいない) */}
|
|
68
|
+
{idx > 0 && (
|
|
69
|
+
<line
|
|
70
|
+
data-cdl-role="chart-stat-divider"
|
|
71
|
+
x1={0}
|
|
72
|
+
y1={区画.余白}
|
|
73
|
+
x2={0}
|
|
74
|
+
y2={枠.h - 区画.余白}
|
|
75
|
+
stroke="var(--cdl-divider, #d4d4d8)"
|
|
76
|
+
strokeWidth={1}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
<text
|
|
81
|
+
data-cdl-role="chart-stat-label"
|
|
82
|
+
x={枠.w / 2}
|
|
83
|
+
y={区画.名前のy}
|
|
84
|
+
fontSize={区画.名前の級}
|
|
85
|
+
fontWeight={700}
|
|
86
|
+
textAnchor="middle"
|
|
87
|
+
fill="var(--cdl-text-dim, #5a6270)"
|
|
88
|
+
>
|
|
89
|
+
{d.label}
|
|
90
|
+
</text>
|
|
91
|
+
|
|
92
|
+
<text
|
|
93
|
+
data-cdl-role="chart-stat-value"
|
|
94
|
+
data-cdl-unresolved={d.unresolved ? "true" : undefined}
|
|
95
|
+
x={枠.w / 2}
|
|
96
|
+
y={区画.数値のy}
|
|
97
|
+
fontSize={数値の級}
|
|
98
|
+
fontWeight={700}
|
|
99
|
+
textAnchor="middle"
|
|
100
|
+
fill={色}
|
|
101
|
+
>
|
|
102
|
+
{数値}
|
|
103
|
+
</text>
|
|
104
|
+
|
|
105
|
+
{/*
|
|
106
|
+
比率の弧。 **輪郭だけの細い線** で描く = 面で塗ると数値より目立ち、
|
|
107
|
+
「数値が主役」 というこの種別の中身が崩れる
|
|
108
|
+
*/}
|
|
109
|
+
<circle
|
|
110
|
+
data-cdl-role="chart-stat-track"
|
|
111
|
+
cx={枠.w / 2}
|
|
112
|
+
cy={区画.弧のy}
|
|
113
|
+
r={区画.弧の半径}
|
|
114
|
+
fill="none"
|
|
115
|
+
stroke="var(--cdl-divider, #d4d4d8)"
|
|
116
|
+
strokeWidth={区画.弧の太さ}
|
|
117
|
+
strokeOpacity={0.5}
|
|
118
|
+
/>
|
|
119
|
+
{frac > 0 && (
|
|
120
|
+
<path
|
|
121
|
+
data-cdl-role="chart-stat-arc"
|
|
122
|
+
d={弧の道(枠.w / 2, 区画.弧のy, 区画.弧の半径, Math.min(frac, 0.9999))}
|
|
123
|
+
fill="none"
|
|
124
|
+
stroke={色}
|
|
125
|
+
strokeWidth={区画.弧の太さ}
|
|
126
|
+
strokeLinecap="round"
|
|
127
|
+
/>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
<text
|
|
131
|
+
data-cdl-role="chart-stat-share"
|
|
132
|
+
x={枠.w / 2}
|
|
133
|
+
y={区画.割合のy}
|
|
134
|
+
fontSize={区画.割合の級}
|
|
135
|
+
textAnchor="middle"
|
|
136
|
+
fill="var(--cdl-text-dim, #5a6270)"
|
|
137
|
+
>
|
|
138
|
+
{割合表記(frac)}
|
|
139
|
+
</text>
|
|
140
|
+
</g>
|
|
141
|
+
);
|
|
142
|
+
})}
|
|
143
|
+
</g>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 区画の割付。
|
|
149
|
+
*
|
|
150
|
+
* 件数で横に等分する。 **縦の位置は件数に依らない** = 件数が変わると数値の高さが動く形に
|
|
151
|
+
* すると、同じ図を並べた時に数値の行が揃わない。
|
|
152
|
+
*/
|
|
153
|
+
function 区画の割付(w: number, h: number, 件数: number) {
|
|
154
|
+
const 余白 = 20;
|
|
155
|
+
const 幅 = 件数 > 0 ? w / 件数 : w;
|
|
156
|
+
const 名前の級 = 14;
|
|
157
|
+
/** 数値の級。 **名前より大きい** のがこの種別の中身 */
|
|
158
|
+
const 数値の級 = 40;
|
|
159
|
+
const 割合の級 = 12;
|
|
160
|
+
const 弧の半径 = 20;
|
|
161
|
+
const 弧の太さ = 4;
|
|
162
|
+
|
|
163
|
+
// 上から 名前 / 数値 / 弧 / 割合 の順に積む。 中身の高さを出してから縦中央に寄せる
|
|
164
|
+
const 中身の高さ = 名前の級 + 12 + 数値の級 + 16 + 弧の半径 * 2 + 8 + 割合の級;
|
|
165
|
+
const 上端 = Math.max(余白, (h - 中身の高さ) / 2);
|
|
166
|
+
|
|
167
|
+
const 名前のy = 上端 + 名前の級;
|
|
168
|
+
const 数値のy = 名前のy + 12 + 数値の級 * 0.78;
|
|
169
|
+
const 弧のy = 数値のy + 16 + 弧の半径;
|
|
170
|
+
const 割合のy = 弧のy + 弧の半径 + 8 + 割合の級;
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
余白,
|
|
174
|
+
名前の級,
|
|
175
|
+
数値の級,
|
|
176
|
+
割合の級,
|
|
177
|
+
弧の半径,
|
|
178
|
+
弧の太さ,
|
|
179
|
+
名前のy,
|
|
180
|
+
数値のy,
|
|
181
|
+
弧のy,
|
|
182
|
+
割合のy,
|
|
183
|
+
枠: (idx: number) => ({ x: 幅 * idx, y: 0, w: 幅, h }),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 字が幅に収まる級を返す。 収まらなければ下限まで縮める。
|
|
189
|
+
*
|
|
190
|
+
* 下限を名前の級より大きく取るのは、縮めた結果 **数値が名前より小さくなる** と、この種別の
|
|
191
|
+
* 中身 (数値が主役) が崩れるため。 下限でも入らない場合は入らないまま描く = 勝手に字を
|
|
192
|
+
* 落とすと数値が別の値に読める。
|
|
193
|
+
*/
|
|
194
|
+
export function 収める級(字: string, 使える幅: number, 初期: number, 下限: number): number {
|
|
195
|
+
let 級 = 初期;
|
|
196
|
+
while (級 > 下限 && textWidth(字, 級) > 使える幅) 級 -= 1;
|
|
197
|
+
return 級;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 12 時から時計回りに `割合` ぶん伸びる弧の道 (線で描く)。
|
|
202
|
+
*
|
|
203
|
+
* **1 周ちょうどは弧で表せない** (始点と終点が同じ点になり向きが決まらない) ため、
|
|
204
|
+
* 呼出側でわずかに欠かして渡す。
|
|
205
|
+
*/
|
|
206
|
+
function 弧の道(cx: number, cy: number, r: number, 割合: number): string {
|
|
207
|
+
const a0 = -Math.PI / 2;
|
|
208
|
+
const a1 = a0 + 割合 * Math.PI * 2;
|
|
209
|
+
const large = 割合 > 0.5 ? 1 : 0;
|
|
210
|
+
const p = (a: number) => `${cx + r * Math.cos(a)} ${cy + r * Math.sin(a)}`;
|
|
211
|
+
return `M ${p(a0)} A ${r} ${r} 0 ${large} 1 ${p(a1)}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 実値の表記。 桁区切りを入れる (`chart-pie` / `chart-radial` と揃える) */
|
|
215
|
+
export function 実値表記(v: number): string {
|
|
216
|
+
return Number.isInteger(v)
|
|
217
|
+
? v.toLocaleString("en-US")
|
|
218
|
+
: v.toLocaleString("en-US", { maximumFractionDigits: 1 });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 取り分の表記。 整数のとき小数を出さない (`chart-pie` / `chart-radial` と揃える) */
|
|
222
|
+
function 割合表記(frac: number): string {
|
|
223
|
+
const v = Math.round(frac * 1000) / 10;
|
|
224
|
+
return `${Number.isInteger(v) ? v : v.toFixed(1)}%`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 印の色。 明示した色が優先され、無ければ図表の系列の色を順に配る */
|
|
228
|
+
function 印の色(tone: Tone | undefined, idx: number): string {
|
|
229
|
+
if (tone) return TONE[tone];
|
|
230
|
+
return chartSeriesColor(idx);
|
|
231
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
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-waffle kind ... 100 個の印で割合を **数えて確かめられる** 図 (cdl#552)。
|
|
9
|
+
*
|
|
10
|
+
* ## 何のための種別か
|
|
11
|
+
*
|
|
12
|
+
* `chart-pie` は角度、`chart-bar` は長さで割合を示すが、どちらも **目測** になる。
|
|
13
|
+
* 「だいたい 4 割」 までは読めても、正確な値は凡例や名札の数字に頼ることになる。
|
|
14
|
+
* 図そのものから値を確かめられないため、図と数字が食い違っていても読み手は気付けない。
|
|
15
|
+
*
|
|
16
|
+
* 1 個を 1% に割り当てた印を 100 個並べれば、**数えれば正確に分かる**。
|
|
17
|
+
*
|
|
18
|
+
* ## 印の数の合計は常に 100 になる
|
|
19
|
+
*
|
|
20
|
+
* 割合をそれぞれ四捨五入すると合計が 100 からずれる (33.3% が 3 件で 99、
|
|
21
|
+
* 16.7% が 6 件で 102)。 ずれたまま描くと **印が余る / 足りない** 図になり、
|
|
22
|
+
* 「数えて確かめられる」 という存在理由が消える。
|
|
23
|
+
*
|
|
24
|
+
* `印の割付` が最大剰余法で配る = まず切り捨てで配り、余った分を小数部の大きい件から 1 個ずつ
|
|
25
|
+
* 足す。 合計は定義上ちょうど 100 になる。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** 印の総数。 1 個 = 1% にするための値で、変えると「数えれば分かる」 が成り立たない */
|
|
29
|
+
export const WAFFLE_TOTAL = 100;
|
|
30
|
+
|
|
31
|
+
/** 1 行に並べる印の数。 `WAFFLE_TOTAL` の平方根 = 正方形に並ぶ */
|
|
32
|
+
export const WAFFLE_COLS = 10;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 割合を印の数へ配る (最大剰余法)。
|
|
36
|
+
*
|
|
37
|
+
* **合計は必ず `WAFFLE_TOTAL` になる**。 それぞれを四捨五入する形では合計がずれ、
|
|
38
|
+
* 印が余る / 足りない図になる。
|
|
39
|
+
*
|
|
40
|
+
* 余りは小数部の大きい件から配る。 同じ小数部が並んだ時は **元の並び順で先の件** に配る =
|
|
41
|
+
* 並べ替えが安定していないと、同じ入力から違う図が出る。
|
|
42
|
+
*/
|
|
43
|
+
export function 印の割付(値: readonly number[]): number[] {
|
|
44
|
+
const 合計 = 値.reduce((a, v) => a + Math.max(0, v), 0);
|
|
45
|
+
if (合計 <= 0) return 値.map(() => 0);
|
|
46
|
+
|
|
47
|
+
const 生 = 値.map((v) => (Math.max(0, v) / 合計) * WAFFLE_TOTAL);
|
|
48
|
+
const 配分 = 生.map((v) => Math.floor(v));
|
|
49
|
+
let 余り = WAFFLE_TOTAL - 配分.reduce((a, v) => a + v, 0);
|
|
50
|
+
|
|
51
|
+
const 順 = 生
|
|
52
|
+
.map((v, i) => ({ i, 小数部: v - Math.floor(v) }))
|
|
53
|
+
// 小数部の大きい順。 同じなら元の並びで先の件が勝つ (同じ入力から同じ図を出すため)。
|
|
54
|
+
//
|
|
55
|
+
// **並び順の指定は変異試験で覆えない**。 `Array.prototype.sort` は ES2019 以降 安定と
|
|
56
|
+
// 定められており、比較が 0 を返した時点で元の並びが保たれる = `|| a.i - b.i` を外しても
|
|
57
|
+
// 結果が変わらない (実測で 25 件とも通った)。 意図を読み手に残すために書いている。
|
|
58
|
+
.sort((a, b) => b.小数部 - a.小数部 || a.i - b.i);
|
|
59
|
+
|
|
60
|
+
for (let k = 0; 余り > 0; k = (k + 1) % 順.length) {
|
|
61
|
+
const i = 順[k]!.i;
|
|
62
|
+
配分[i] = (配分[i] ?? 0) + 1;
|
|
63
|
+
余り -= 1;
|
|
64
|
+
}
|
|
65
|
+
return 配分;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function ChartWaffleNode({
|
|
69
|
+
node,
|
|
70
|
+
active,
|
|
71
|
+
stateValues = {},
|
|
72
|
+
}: {
|
|
73
|
+
node: LaidNode;
|
|
74
|
+
active: boolean;
|
|
75
|
+
stateValues?: Record<string, string>;
|
|
76
|
+
}): JSX.Element {
|
|
77
|
+
const x0 = node.cx - node.w / 2;
|
|
78
|
+
const y0 = node.cy - node.h / 2;
|
|
79
|
+
const data = resolveChartData(node.chartData ?? [], stateValues);
|
|
80
|
+
const 配分 = 印の割付(data.map((d) => d.value));
|
|
81
|
+
|
|
82
|
+
const PAD = 20;
|
|
83
|
+
const 名前の級 = 14;
|
|
84
|
+
const 数値の級 = 13;
|
|
85
|
+
const 行の高さ = 40;
|
|
86
|
+
const 一覧と格子の間 = 28;
|
|
87
|
+
const 印の幅 = 18;
|
|
88
|
+
|
|
89
|
+
const 数値の文字 = data.map((d, i) => `${実値表記(d.value)} ${配分[i] ?? 0}%`);
|
|
90
|
+
/** 一覧の幅。 中身から決める = 割り当てを固定すると使わない分が死ぬ (cdl#542) */
|
|
91
|
+
const 一覧の幅 =
|
|
92
|
+
印の幅 +
|
|
93
|
+
Math.max(
|
|
94
|
+
0,
|
|
95
|
+
...data.map((d, i) =>
|
|
96
|
+
Math.max(textWidth(d.label ?? "", 名前の級), textWidth(数値の文字[i] ?? "", 数値の級)),
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const 使える高さ = node.h - PAD * 2;
|
|
101
|
+
const 格子に使える幅 = node.w - PAD * 2 - 一覧の幅 - 一覧と格子の間;
|
|
102
|
+
/** 升の 1 辺。 縦横の小さい方に収める (格子は正方形) */
|
|
103
|
+
const 升 = Math.max(4, Math.min(使える高さ, 格子に使える幅) / WAFFLE_COLS);
|
|
104
|
+
const 印 = 升 * 0.78;
|
|
105
|
+
const 格子の左 = PAD + 一覧の幅 + 一覧と格子の間;
|
|
106
|
+
const 格子の上 = PAD + (使える高さ - 升 * WAFFLE_COLS) / 2;
|
|
107
|
+
|
|
108
|
+
/** 印 1 個ごとの持ち主。 上の行から左詰めで配る = 読む向きと同じで数えやすい */
|
|
109
|
+
const 持ち主: number[] = [];
|
|
110
|
+
配分.forEach((n, idx) => {
|
|
111
|
+
for (let k = 0; k < n; k++) 持ち主.push(idx);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const 一覧の始まり = PAD + (使える高さ - 行の高さ * data.length) / 2 + 行の高さ / 2;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<g transform={`translate(${x0} ${y0})`}>
|
|
118
|
+
<rect
|
|
119
|
+
data-cdl-role="node-body"
|
|
120
|
+
x={0}
|
|
121
|
+
y={0}
|
|
122
|
+
width={node.w}
|
|
123
|
+
height={node.h}
|
|
124
|
+
rx={16}
|
|
125
|
+
fill="var(--cdl-node-fill, #ffffff)"
|
|
126
|
+
stroke={active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)"}
|
|
127
|
+
strokeWidth={active ? 3 : 1.25}
|
|
128
|
+
/>
|
|
129
|
+
|
|
130
|
+
{/*
|
|
131
|
+
印。 **必ず 100 個描く** = 持ち主のいない印も地の色で描く。 描かないと
|
|
132
|
+
「全体のうちどれだけか」 の分母が消え、棒グラフと同じ図になる
|
|
133
|
+
*/}
|
|
134
|
+
{Array.from({ length: WAFFLE_TOTAL }, (_, k) => {
|
|
135
|
+
const 行 = Math.floor(k / WAFFLE_COLS);
|
|
136
|
+
const 列 = k % WAFFLE_COLS;
|
|
137
|
+
const owner = 持ち主[k];
|
|
138
|
+
return (
|
|
139
|
+
<rect
|
|
140
|
+
key={`cell-${k}`}
|
|
141
|
+
data-cdl-role="chart-waffle-cell"
|
|
142
|
+
data-cdl-waffle-owner={owner === undefined ? "" : String(owner)}
|
|
143
|
+
x={格子の左 + 升 * 列 + (升 - 印) / 2}
|
|
144
|
+
y={格子の上 + 升 * 行 + (升 - 印) / 2}
|
|
145
|
+
width={印}
|
|
146
|
+
height={印}
|
|
147
|
+
rx={2}
|
|
148
|
+
fill={owner === undefined ? "var(--cdl-divider, #d4d4d8)" : 印の色(data[owner]!.tone, owner)}
|
|
149
|
+
fillOpacity={owner === undefined ? 0.35 : 1}
|
|
150
|
+
/>
|
|
151
|
+
);
|
|
152
|
+
})}
|
|
153
|
+
|
|
154
|
+
{/* 一覧。 印の数をそのまま出す = 図を数えた結果と突き合わせられる */}
|
|
155
|
+
{data.map((d, idx) => {
|
|
156
|
+
const y = 一覧の始まり + 行の高さ * idx;
|
|
157
|
+
return (
|
|
158
|
+
<g key={`item-${idx}`} data-cdl-role="chart-waffle-item">
|
|
159
|
+
<rect x={PAD} y={y - 11} width={4} height={22} rx={2} fill={印の色(d.tone, idx)} />
|
|
160
|
+
<text
|
|
161
|
+
x={PAD + 印の幅}
|
|
162
|
+
y={y + 1}
|
|
163
|
+
fontSize={名前の級}
|
|
164
|
+
fontWeight={700}
|
|
165
|
+
fill="var(--cdl-text, #1a1f2a)"
|
|
166
|
+
>
|
|
167
|
+
{d.label}
|
|
168
|
+
</text>
|
|
169
|
+
<text
|
|
170
|
+
data-cdl-role="chart-waffle-count"
|
|
171
|
+
data-cdl-unresolved={d.unresolved ? "true" : undefined}
|
|
172
|
+
x={PAD + 印の幅}
|
|
173
|
+
y={y + 20}
|
|
174
|
+
fontSize={数値の級}
|
|
175
|
+
fill="var(--cdl-text-dim, #5a6270)"
|
|
176
|
+
>
|
|
177
|
+
{数値の文字[idx]}
|
|
178
|
+
</text>
|
|
179
|
+
</g>
|
|
180
|
+
);
|
|
181
|
+
})}
|
|
182
|
+
</g>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 実値の表記。 桁区切りを入れる (他の図表と揃える) */
|
|
187
|
+
function 実値表記(v: number): string {
|
|
188
|
+
return Number.isInteger(v)
|
|
189
|
+
? v.toLocaleString("en-US")
|
|
190
|
+
: v.toLocaleString("en-US", { maximumFractionDigits: 1 });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 印の色。 明示した色が優先され、無ければ図表の系列の色を順に配る */
|
|
194
|
+
function 印の色(tone: Tone | undefined, idx: number): string {
|
|
195
|
+
if (tone) return TONE[tone];
|
|
196
|
+
return chartSeriesColor(idx);
|
|
197
|
+
}
|