@cardenelabs/cdl 0.7.0 → 0.9.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 +401 -0
- package/SPEC.md +32 -17
- package/dist/index.cjs +700 -306
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +68 -14
- package/dist/index.d.ts +68 -14
- package/dist/index.js +700 -306
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +572 -196
- 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 +572 -196
- package/dist/react.js.map +1 -1
- package/dist/{render-DquCvgOB.d.cts → render-hmFRAabt.d.cts} +33 -2
- package/dist/{render-DquCvgOB.d.ts → render-hmFRAabt.d.ts} +33 -2
- package/package.json +6 -3
- package/src/kinds/box-lines.ts +120 -0
- package/src/kinds/gantt.tsx +152 -24
- package/src/kinds/mind-map.tsx +50 -12
- package/src/kinds/quadrant.tsx +28 -2
- package/src/kinds/shape-blockchain.tsx +65 -12
- package/src/kinds/shape-region.tsx +152 -34
- package/src/kinds/tree.tsx +92 -20
- package/src/layout/collisions.ts +10 -2
- package/src/layout/footer-shape.ts +86 -24
- package/src/layout/lifeline.ts +80 -0
- package/src/layout/spec.ts +21 -2
- package/src/layout/text-width.ts +92 -4
- package/src/layout/viewbox.ts +0 -8
- package/src/layout.ts +8 -1
- package/src/presets.ts +120 -22
- package/src/render/header.tsx +3 -1
- package/src/render/payload-binding.ts +31 -8
- package/src/render/stage.tsx +17 -34
- package/src/render/template-fields.ts +212 -0
- package/src/render/utils.ts +32 -2
- package/src/render.tsx +2 -9
- package/src/types.ts +33 -2
- package/src/validate.ts +27 -9
- package/src/visual-validate.ts +34 -1
- 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
|
-
/**
|
|
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";
|
|
@@ -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
|
-
/**
|
|
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";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cardenelabs/cdl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -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
|
+
}
|
package/src/kinds/gantt.tsx
CHANGED
|
@@ -33,21 +33,141 @@ export function GanttNode({
|
|
|
33
33
|
const rowCount = tasks.length || 1;
|
|
34
34
|
const rowH = Math.max(28, (canvasH - rowGap * (rowCount + 1)) / rowCount);
|
|
35
35
|
|
|
36
|
+
// 位置を先に整える (#468 Round 5)。 端も始まりも状態から来るため、 そのまま使うと
|
|
37
|
+
// 図が壊れる形が 3 つある (いずれも実測)。
|
|
38
|
+
//
|
|
39
|
+
// | 入力 | そのまま使った時 |
|
|
40
|
+
// |---|---|
|
|
41
|
+
// | 始まり `1e308` | `x="Infinity"` |
|
|
42
|
+
// | 始まり `-1e308` | `x="-Infinity" width="Infinity"` |
|
|
43
|
+
// | 始まり 10 / 端 1 | 帯が図の外 (`x=2739`) |
|
|
44
|
+
//
|
|
45
|
+
// 負は 0 に寄せ、 端が始まりより手前なら始まりに揃える。 落とさず整えるのは、 状態が動く
|
|
46
|
+
// 途中で一瞬だけ逆転する形があるため (帯が消えるより潰れて見える方がよい)。
|
|
47
|
+
const 位置の上限 = 1e6;
|
|
48
|
+
const 収めた = tasks.map((t) => {
|
|
49
|
+
const 始 = Math.min(位置の上限, Math.max(0, Number.isFinite(t.startIdx) ? t.startIdx : 0));
|
|
50
|
+
const 終 = Math.min(位置の上限, Math.max(始, Number.isFinite(t.endIdx) ? t.endIdx : 始));
|
|
51
|
+
return { ...t, startIdx: 始, endIdx: 終 };
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// 書き手が宣言した横軸の範囲 (#483)。 有限の正の数だけ採る = 壊れた値で尺を作ると帯が
|
|
55
|
+
// 図の外へ出るため、 それより自動に落ちる方が読める図になる。
|
|
56
|
+
//
|
|
57
|
+
// 位置は 0 から `宣言した尺 - 1` までで、 `帯の右` は 1 目盛り分先まで伸びるため、
|
|
58
|
+
// 最後の目盛りにある帯の右端がちょうど図の右端に来る。
|
|
59
|
+
const 宣言 = node.ganttAxisMax;
|
|
60
|
+
const 宣言した尺 = typeof 宣言 === "number" && Number.isFinite(宣言) && 宣言 > 0
|
|
61
|
+
? Math.min(位置の上限, Math.ceil(宣言))
|
|
62
|
+
: undefined;
|
|
63
|
+
|
|
36
64
|
// 帯の右端が状態で動くと端数が出る。 目盛りは本数を数えるので切り上げて整数にする
|
|
37
65
|
// (静止した図では endIdx が整数なので切り上げても値は変わらない)。
|
|
38
|
-
|
|
66
|
+
//
|
|
67
|
+
// **始まりも見る**。 上の整えで端は始まり以上になるため、 端だけでも同じ値になる
|
|
68
|
+
// (この 2 つは今は等価)。 整えを緩めた時に帯が図の外へ出る形を作らないための二重。
|
|
69
|
+
const maxIdx = 宣言した尺 ?? Math.max(
|
|
70
|
+
1,
|
|
71
|
+
Math.ceil(Math.max(...収めた.flatMap((t) => [t.startIdx + 1, t.endIdx + 1]), 1)),
|
|
72
|
+
);
|
|
39
73
|
const cellW = canvasW / maxIdx;
|
|
40
74
|
|
|
75
|
+
// 宣言した範囲から外れる帯は **右端の目盛りに寄せる** (#483)。 尺を広げて収める形は採らない
|
|
76
|
+
// = 広げた瞬間に他の task の帯が動き、 範囲を宣言した意味が消える。
|
|
77
|
+
//
|
|
78
|
+
// 落とす形も採らない (Issue の 反例 = 帯が図の外へ消えてはいけない)。 寄せた帯には印を付け、
|
|
79
|
+
// 実際の位置と違うことを読み取れるようにする。
|
|
80
|
+
const 最後の目盛り = maxIdx - 1;
|
|
81
|
+
const 整えた = 宣言した尺 === undefined
|
|
82
|
+
? 収めた
|
|
83
|
+
: 収めた.map((t) => {
|
|
84
|
+
const 始 = Math.min(t.startIdx, 最後の目盛り);
|
|
85
|
+
const 終 = Math.min(t.endIdx, 最後の目盛り);
|
|
86
|
+
const 寄せた = 始 !== t.startIdx || 終 !== t.endIdx;
|
|
87
|
+
return 寄せた ? { ...t, startIdx: 始, endIdx: 終, clipped: true } : t;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// **描く目盛りの本数だけに上限を置く** (#468)。 端は状態から来るため、 大きな値を書かれると
|
|
91
|
+
// 目盛りをその数だけ作ることになる (実測 = `4294967295` で `Array.from` が
|
|
92
|
+
// `RangeError: Invalid array length`)。
|
|
93
|
+
//
|
|
94
|
+
// **尺 (`maxIdx`) には掛けない**。 掛けると尺が実際の最大より小さくなり、 上限を超える位置の
|
|
95
|
+
// 帯が図の外へ出る (Round 2 の実測 = 位置 250 の帯が右端 800.6、 図の右端は 672)。
|
|
96
|
+
// 本数が上限を超える時は間引いて描く = 目盛りは目安なので、 全部描けなくても図は読める。
|
|
97
|
+
const 目盛りの上限 = 200;
|
|
98
|
+
const 目盛りの間隔 = Math.max(1, Math.ceil(maxIdx / 目盛りの上限));
|
|
99
|
+
const 目盛りの本数 = Math.ceil(maxIdx / 目盛りの間隔);
|
|
100
|
+
|
|
41
101
|
const xAt = (idx: number) => PAD_LEFT + idx * cellW;
|
|
42
102
|
const yAt = (row: number) => PAD_TOP + rowGap + row * (rowH + rowGap);
|
|
43
103
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
104
|
+
// 目盛りの名前は task が持つ (開始と終了の両方)。 どちらも指していない位置は
|
|
105
|
+
// **空にする** = index の数字をそのまま出すと、 月や四半期の並びに数字が 1 つ混ざって
|
|
106
|
+
// 与えた値のように読める (実測 = "1月 2月 2 4月 5月" の 3 番目)。
|
|
107
|
+
//
|
|
108
|
+
// 引き継ぎ (前の名前を繰り返す) は採らない。 別の目盛りに同じ名前が並ぶと、 どちらが
|
|
109
|
+
// その task の境目か読めなくなる。
|
|
110
|
+
//
|
|
111
|
+
// 規則は 3 つ。 いずれも決めておかないと task の書き順で結果が変わる。
|
|
112
|
+
//
|
|
113
|
+
// 1. 同じ位置を複数の task が指す時は **先に書いた task** を採る
|
|
114
|
+
// 2. 開始の名前を終了の名前より優先する
|
|
115
|
+
// 3. 空文字は名前が無いものとして扱い、 次の候補へ落ちる
|
|
116
|
+
//
|
|
117
|
+
// 端数を持つ位置は四捨五入で合わせる。 端は状態に追随して端数を持つ (`{signal}` で動く)
|
|
118
|
+
// 一方、 目盛りは整数の位置にしか無いため、 厳密一致だと動いている間だけ名前が消える
|
|
119
|
+
// (実測 = endIdx 1.5 で終了の名前が出なくなった)。
|
|
120
|
+
//
|
|
121
|
+
// **整数の一致を端数より優先する**。 まとめて四捨五入すると、 動いている task が
|
|
122
|
+
// その位置を静的に指す task の名前を奪う (実測 = endIdx 1.5 の task が先にあると、
|
|
123
|
+
// endIdx 2 の task の名前が消えた)。
|
|
124
|
+
//
|
|
125
|
+
// 表を 1 度だけ作る = 目盛りごとに task を探すと目盛り数 × task 数になり、 動きの
|
|
126
|
+
// 1 コマごとに払う。
|
|
127
|
+
const 名前 = { 整数の開始: new Map<number, string>(), 整数の終了: new Map<number, string>(), 端数の開始: new Map<number, string>(), 端数の終了: new Map<number, string>() };
|
|
128
|
+
const 置く = (表: Map<number, string>, 位置: number, label: string) => {
|
|
129
|
+
// 空文字は名前が無いものとして扱う。 同じ位置は先に書いた task を採る
|
|
130
|
+
if (label !== "" && Number.isFinite(位置) && !表.has(位置)) 表.set(位置, label);
|
|
131
|
+
};
|
|
132
|
+
// 名前は宣言範囲へ寄せる前の位置から作る。 範囲外 task の名前まで右端へ
|
|
133
|
+
// 寄せると、 実際に右端を指す task の名前を上書きして軸の意味が変わる。
|
|
134
|
+
for (const t of 収めた) {
|
|
135
|
+
const 整数の開始 = Number.isInteger(t.startIdx);
|
|
136
|
+
置く(整数の開始 ? 名前.整数の開始 : 名前.端数の開始, Math.round(t.startIdx), t.startLabel);
|
|
137
|
+
const 整数の終了 = Number.isInteger(t.endIdx);
|
|
138
|
+
置く(整数の終了 ? 名前.整数の終了 : 名前.端数の終了, Math.round(t.endIdx), t.endLabel);
|
|
139
|
+
}
|
|
140
|
+
const 名前を引く = (i: number): string =>
|
|
141
|
+
名前.整数の開始.get(i) ?? 名前.整数の終了.get(i) ?? 名前.端数の開始.get(i) ?? 名前.端数の終了.get(i) ?? "";
|
|
142
|
+
|
|
143
|
+
// 間引いた位置に加えて、 **名前のある位置は必ず残す** (#468 Round 3)。 等間隔だけで選ぶと
|
|
144
|
+
// task の境目に付いた名前が落ちる (実測 = 位置 202 まで伸ばすと間隔 2 になり、 位置 1 と 3 の
|
|
145
|
+
// 名前が消えた)。 名前は task から来るので、 残す数は task 数で頭打ちになる。
|
|
146
|
+
//
|
|
147
|
+
// 位置そのものも持ち回る = 間隔が 1 でない時に添字と位置がずれるため、 線と名前を置く場所を
|
|
148
|
+
// 位置から出す。
|
|
149
|
+
const 位置の集合 = new Set<number>();
|
|
150
|
+
for (let n = 0; n < 目盛りの本数; n += 1) 位置の集合.add(n * 目盛りの間隔);
|
|
151
|
+
for (const 表 of [名前.整数の開始, 名前.整数の終了, 名前.端数の開始, 名前.端数の終了]) {
|
|
152
|
+
for (const i of 表.keys()) if (i >= 0 && i < maxIdx) 位置の集合.add(i);
|
|
153
|
+
}
|
|
154
|
+
const gridLabels = [...位置の集合]
|
|
155
|
+
.sort((a, b) => a - b)
|
|
156
|
+
.map((i) => ({ 位置: i, label: 名前を引く(i) }));
|
|
157
|
+
|
|
158
|
+
// 帯の左右に置く余白。 **目盛りの幅に比例させる** (#468 Round 7)。
|
|
159
|
+
//
|
|
160
|
+
// 固定 (6) だと、 目盛りが細かい図で帯が消える。 1 目盛りの幅が 6 ちょうどだと幅 0、
|
|
161
|
+
// それより小さいと負になる (実測 = 位置 1e6 で -5.999、 位置 85 で 0)。
|
|
162
|
+
// 目盛りが広い図 (通常の工程表) では 6 のままなので見た目は変わらない。
|
|
163
|
+
const 帯の余白 = Math.min(6, cellW / 2);
|
|
164
|
+
// 帯の左端と右端。 **依存矢印も同じ式を使う** (#468 Round 9)。 別々に持つと、 余白を
|
|
165
|
+
// 可変にした時に矢印の基準点だけが旧い端に取り残される
|
|
166
|
+
const 帯の左 = (idx: number): number => xAt(idx) + 帯の余白 / 2;
|
|
167
|
+
const 帯の右 = (idx: number): number => xAt(idx) + cellW - 帯の余白 / 2;
|
|
48
168
|
|
|
49
|
-
const taskById = new Map(
|
|
50
|
-
const rowOf = new Map(
|
|
169
|
+
const taskById = new Map(整えた.map((t) => [t.id, t]));
|
|
170
|
+
const rowOf = new Map(整えた.map((t, i) => [t.id, i]));
|
|
51
171
|
|
|
52
172
|
return (
|
|
53
173
|
<g transform={`translate(${x0} ${y0})`}>
|
|
@@ -63,9 +183,10 @@ export function GanttNode({
|
|
|
63
183
|
strokeWidth={active ? 3 : 1.25}
|
|
64
184
|
/>
|
|
65
185
|
|
|
66
|
-
{gridLabels.map((
|
|
186
|
+
{gridLabels.map(({ 位置: i, label }) => (
|
|
67
187
|
<g key={`grid-${i}`}>
|
|
68
188
|
<line
|
|
189
|
+
data-cdl-role="gantt-grid"
|
|
69
190
|
x1={xAt(i)}
|
|
70
191
|
y1={PAD_TOP}
|
|
71
192
|
x2={xAt(i)}
|
|
@@ -75,15 +196,18 @@ export function GanttNode({
|
|
|
75
196
|
strokeDasharray="3 3"
|
|
76
197
|
opacity={0.5}
|
|
77
198
|
/>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
199
|
+
{label === "" ? null : (
|
|
200
|
+
<text
|
|
201
|
+
data-cdl-role="gantt-tick"
|
|
202
|
+
x={xAt(i) + cellW / 2}
|
|
203
|
+
y={PAD_TOP + canvasH + 18}
|
|
204
|
+
fontSize={11}
|
|
205
|
+
textAnchor="middle"
|
|
206
|
+
fill="var(--cdl-text-dim, #5a6270)"
|
|
207
|
+
>
|
|
208
|
+
{label}
|
|
209
|
+
</text>
|
|
210
|
+
)}
|
|
87
211
|
</g>
|
|
88
212
|
))}
|
|
89
213
|
|
|
@@ -97,7 +221,7 @@ export function GanttNode({
|
|
|
97
221
|
opacity={0.5}
|
|
98
222
|
/>
|
|
99
223
|
|
|
100
|
-
{
|
|
224
|
+
{整えた.map((t, row) => (
|
|
101
225
|
<text
|
|
102
226
|
key={`label-${t.id}`}
|
|
103
227
|
x={PAD_LEFT - 12}
|
|
@@ -111,15 +235,19 @@ export function GanttNode({
|
|
|
111
235
|
</text>
|
|
112
236
|
))}
|
|
113
237
|
|
|
114
|
-
{
|
|
115
|
-
const bx =
|
|
238
|
+
{整えた.map((t, row) => {
|
|
239
|
+
const bx = 帯の左(t.startIdx);
|
|
116
240
|
const by = yAt(row);
|
|
117
|
-
|
|
241
|
+
// 0 で切るのは二重。 余白が目盛りの幅の半分で頭打ちのため、 引いた結果は必ず
|
|
242
|
+
// 目盛りの幅の半分以上になる = **この分岐に届く入力を今は作れない** (変異試験で確認)。
|
|
243
|
+
// 余白の決め方を変えた時に負の幅を出さないために残す
|
|
244
|
+
const bw = Math.max(0, 帯の右(t.endIdx) - bx);
|
|
118
245
|
return (
|
|
119
246
|
<g key={`bar-${t.id}`}>
|
|
120
247
|
<rect
|
|
121
248
|
data-cdl-role="gantt-bar"
|
|
122
249
|
data-cdl-unresolved={t.unresolved ? "true" : undefined}
|
|
250
|
+
data-cdl-clipped={"clipped" in t && t.clipped ? "true" : undefined}
|
|
123
251
|
x={bx}
|
|
124
252
|
y={by}
|
|
125
253
|
width={bw}
|
|
@@ -143,15 +271,15 @@ export function GanttNode({
|
|
|
143
271
|
);
|
|
144
272
|
})}
|
|
145
273
|
|
|
146
|
-
{
|
|
274
|
+
{整えた
|
|
147
275
|
.filter((t) => t.dependsOn && taskById.has(t.dependsOn))
|
|
148
276
|
.map((t) => {
|
|
149
277
|
const parent = taskById.get(t.dependsOn!)!;
|
|
150
278
|
const parentRow = rowOf.get(parent.id)!;
|
|
151
279
|
const childRow = rowOf.get(t.id)!;
|
|
152
|
-
const parentBarRight =
|
|
280
|
+
const parentBarRight = 帯の右(parent.endIdx);
|
|
153
281
|
const parentMidY = yAt(parentRow) + rowH / 2;
|
|
154
|
-
const childBarLeft =
|
|
282
|
+
const childBarLeft = 帯の左(t.startIdx);
|
|
155
283
|
const childMidY = yAt(childRow) + rowH / 2;
|
|
156
284
|
const arrowHeadSize = 8;
|
|
157
285
|
// arrow head 三角形の頂点 (= tipX + arrowHeadSize) が bar 左辺の 10px 手前に来る
|
package/src/kinds/mind-map.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { JSX } from "react";
|
|
2
|
+
import { 箱に積む } from "./box-lines";
|
|
2
3
|
import type { LaidNode, Tone, MindBranchNode } from "../types";
|
|
3
4
|
import { TONE } from "../render/tone";
|
|
4
5
|
import { resolveMindData } from "../render/payload-binding";
|
|
@@ -76,7 +77,22 @@ export function MindMapNode({
|
|
|
76
77
|
/>
|
|
77
78
|
))}
|
|
78
79
|
|
|
79
|
-
{layout.nodes.map((n) =>
|
|
80
|
+
{layout.nodes.map((n) => {
|
|
81
|
+
// 補足は箱の中の 2 行目に出す (#471)。 箱の高さは変えないため、 入らない時は落ちる
|
|
82
|
+
const 行 = 箱に積む(
|
|
83
|
+
[
|
|
84
|
+
{ 役割: "title" as const, text: n.title, fontSize: n.isRoot ? 15 : 13 },
|
|
85
|
+
{ 役割: "subtitle" as const, text: n.subtitle ?? "", fontSize: 9 },
|
|
86
|
+
],
|
|
87
|
+
n.w,
|
|
88
|
+
n.h,
|
|
89
|
+
);
|
|
90
|
+
// 判定は行数でなく「補足を渡したか」 で行う (理由は tree.tsx と同じ、 #471 Round 1)
|
|
91
|
+
const 補足あり = (n.subtitle ?? "").trim() !== "";
|
|
92
|
+
const 文字の色 = n.isRoot
|
|
93
|
+
? "var(--cdl-text-on-tone, #ffffff)"
|
|
94
|
+
: "var(--cdl-chip-text, #1a1f2a)";
|
|
95
|
+
return (
|
|
80
96
|
<g key={`n-${n.id}`} data-cdl-unresolved={n.unresolved ? "true" : undefined}>
|
|
81
97
|
<rect
|
|
82
98
|
x={n.x - n.w / 2}
|
|
@@ -88,18 +104,37 @@ export function MindMapNode({
|
|
|
88
104
|
stroke={n.isRoot ? toneColor(n.tone ?? "accent") : toneColor(n.tone)}
|
|
89
105
|
strokeWidth={n.isRoot ? 2 : 1.75}
|
|
90
106
|
/>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
107
|
+
{補足あり ? (
|
|
108
|
+
行.map((r) => (
|
|
109
|
+
<text
|
|
110
|
+
key={r.役割}
|
|
111
|
+
data-cdl-role={r.役割 === "subtitle" ? "mind-node-subtitle" : undefined}
|
|
112
|
+
x={n.x}
|
|
113
|
+
y={n.y + r.dy}
|
|
114
|
+
fontSize={r.fontSize}
|
|
115
|
+
fontWeight={r.役割 === "title" ? (n.isRoot ? 700 : 600) : 500}
|
|
116
|
+
textAnchor="middle"
|
|
117
|
+
fill={文字の色}
|
|
118
|
+
opacity={r.役割 === "subtitle" ? 0.85 : undefined}
|
|
119
|
+
>
|
|
120
|
+
{r.text}
|
|
121
|
+
</text>
|
|
122
|
+
))
|
|
123
|
+
) : (
|
|
124
|
+
<text
|
|
125
|
+
x={n.x}
|
|
126
|
+
y={n.y + 5}
|
|
127
|
+
fontSize={n.isRoot ? 15 : 13}
|
|
128
|
+
fontWeight={n.isRoot ? 700 : 600}
|
|
129
|
+
textAnchor="middle"
|
|
130
|
+
fill={文字の色}
|
|
131
|
+
>
|
|
132
|
+
{n.title}
|
|
133
|
+
</text>
|
|
134
|
+
)}
|
|
101
135
|
</g>
|
|
102
|
-
|
|
136
|
+
);
|
|
137
|
+
})}
|
|
103
138
|
</g>
|
|
104
139
|
);
|
|
105
140
|
}
|
|
@@ -107,6 +142,8 @@ export function MindMapNode({
|
|
|
107
142
|
type LayoutNode = {
|
|
108
143
|
id: string;
|
|
109
144
|
title: string;
|
|
145
|
+
/** 箱の 2 行目に出す補足 (#471) */
|
|
146
|
+
subtitle?: string;
|
|
110
147
|
x: number;
|
|
111
148
|
y: number;
|
|
112
149
|
w: number;
|
|
@@ -297,6 +334,7 @@ export function computeMindMapLayout(
|
|
|
297
334
|
nodes.push({
|
|
298
335
|
id: node.id,
|
|
299
336
|
title: node.title,
|
|
337
|
+
...(node.subtitle !== undefined ? { subtitle: node.subtitle } : {}),
|
|
300
338
|
x,
|
|
301
339
|
y,
|
|
302
340
|
w,
|
package/src/kinds/quadrant.tsx
CHANGED
|
@@ -35,6 +35,10 @@ export function QuadrantNode({
|
|
|
35
35
|
// 名前の前後に矢印と間隔 (12) が付くので、 その分も見込む。
|
|
36
36
|
const AXIS_LABEL_FONT = 12;
|
|
37
37
|
const PAD_MIN = 60;
|
|
38
|
+
// 項目の枠。 名前の字幅に左右の余白を足した幅にする (#457)
|
|
39
|
+
const ITEM_FONT = 13;
|
|
40
|
+
const ITEM_PAD_X = 12;
|
|
41
|
+
const ITEM_MIN_W = 48;
|
|
38
42
|
const axisLabelPad = (text: string): number =>
|
|
39
43
|
Math.max(PAD_MIN, measureTextWidth(`← ${text}`, { fontSize: AXIS_LABEL_FONT, fontWeight: 700 }) + 12);
|
|
40
44
|
// 名前が長くても図そのものは残す。 上限を置かないと、 名前が箱の幅を超えた時に
|
|
@@ -145,6 +149,26 @@ export function QuadrantNode({
|
|
|
145
149
|
const qxStart = (isLeft ? PAD_LEFT : cx) + 18;
|
|
146
150
|
const qyStart = (isTop ? PAD_TOP : cy) + 44;
|
|
147
151
|
const qHalfW = halfW - 36;
|
|
152
|
+
// 枠の幅は名前に合わせる。 固定だと短い名前で枠が間延びし、 狭い図では名前が枠から
|
|
153
|
+
// 出る (実測 = 5 文字の「重複を消す」 が 70px で、 枠が狭い図では越えた)。
|
|
154
|
+
//
|
|
155
|
+
// 頭打ちは象限に収まる幅 (qHalfW)。 これを超えると隣の象限へ出るため、 名前が長い時は
|
|
156
|
+
// 枠を伸ばさず名前が枠から出る形にする。 軸の名前と同じ考え方で、 溢れても図は残す。
|
|
157
|
+
//
|
|
158
|
+
// 下限 (ITEM_MIN_W) は **頭打ちより優先しない**。 図が極端に狭いと qHalfW が下限を
|
|
159
|
+
// 下回り、 下限を通すと枠が象限から出る。
|
|
160
|
+
//
|
|
161
|
+
// 余地が無い図では項目を描かない。 qHalfW は負にもなり (実測 = 幅 180 の図で -6)、
|
|
162
|
+
// 幅を 0 に切っても **枠の開始位置 (qxStart) が既に象限の外** にある
|
|
163
|
+
// (実測 = 幅 120 の図で開始 78、 象限の右端 60)。 描く場所が無い時に 0 幅の枠を
|
|
164
|
+
// 象限の外へ置くより、 何も描かない方が図として正しい。
|
|
165
|
+
const 枠の余地 = qHalfW;
|
|
166
|
+
if (枠の余地 <= 0) return null;
|
|
167
|
+
const 枠の幅 = (title: string) =>
|
|
168
|
+
Math.min(
|
|
169
|
+
枠の余地,
|
|
170
|
+
Math.max(ITEM_MIN_W, measureTextWidth(title, { fontSize: ITEM_FONT, fontWeight: 600 }) + ITEM_PAD_X * 2),
|
|
171
|
+
);
|
|
148
172
|
const qHalfH = halfH - 60;
|
|
149
173
|
const gap = 10;
|
|
150
174
|
const rowH = 34;
|
|
@@ -158,14 +182,16 @@ export function QuadrantNode({
|
|
|
158
182
|
data-cdl-unresolved={it.unresolved ? "true" : undefined}
|
|
159
183
|
x={qxStart}
|
|
160
184
|
y={py}
|
|
161
|
-
width={
|
|
185
|
+
width={枠の幅(it.title)}
|
|
162
186
|
height={rowH}
|
|
163
187
|
rx={6}
|
|
164
188
|
fill="var(--cdl-node-fill, #ffffff)"
|
|
165
189
|
stroke={quadrantAccent[q]}
|
|
166
190
|
strokeWidth={1.5}
|
|
167
191
|
/>
|
|
168
|
-
|
|
192
|
+
{/* 字間を 0 に固定する。 枠の幅は字幅の見積り (letterSpacing 0 前提) で決めるため、
|
|
193
|
+
外から字間を継承すると見積りより名前が広がって枠から出る (#457 Round 4) */}
|
|
194
|
+
<text x={qxStart + ITEM_PAD_X} y={py + rowH / 2 + 5} fontSize={ITEM_FONT} fontWeight={600} letterSpacing={0} fill="var(--cdl-text, #1a1f2a)">
|
|
169
195
|
{it.title}
|
|
170
196
|
</text>
|
|
171
197
|
</g>
|