@cardenelabs/cdl 0.5.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/LICENSE +21 -0
- package/README.md +343 -0
- package/SPEC.md +374 -0
- package/dist/index.cjs +28085 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3415 -0
- package/dist/index.d.ts +3415 -0
- package/dist/index.js +27962 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +23043 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +2 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +23041 -0
- package/dist/react.js.map +1 -0
- package/dist/render-C78lIXeC.d.cts +2449 -0
- package/dist/render-C78lIXeC.d.ts +2449 -0
- package/examples/quick-start.md +88 -0
- package/package.json +79 -0
- package/src/anim/core/easing.ts +77 -0
- package/src/anim/core/timeline.ts +335 -0
- package/src/anim/core/types.ts +42 -0
- package/src/anim/index.ts +20 -0
- package/src/anim/react/index.ts +3 -0
- package/src/anim/react/useReducedMotion.ts +27 -0
- package/src/anim/react/useTimeline.ts +48 -0
- package/src/assert-never.ts +16 -0
- package/src/author-intent-verify.ts +587 -0
- package/src/builder.ts +3740 -0
- package/src/compile.ts +12 -0
- package/src/dom-verify-core.ts +121 -0
- package/src/dom-verify-types.ts +21 -0
- package/src/dom-verify.ts +948 -0
- package/src/event-handler/index.ts +184 -0
- package/src/formula/ast.ts +46 -0
- package/src/formula/evaluator.ts +163 -0
- package/src/formula/formula-computeds.ts +83 -0
- package/src/formula/index.ts +5 -0
- package/src/formula/parser.ts +399 -0
- package/src/index.ts +284 -0
- package/src/input-signals.ts +74 -0
- package/src/kinds/actor.tsx +79 -0
- package/src/kinds/card.tsx +61 -0
- package/src/kinds/chart-bar.tsx +121 -0
- package/src/kinds/chart-line.tsx +163 -0
- package/src/kinds/chart-pie.tsx +107 -0
- package/src/kinds/compact-title.ts +164 -0
- package/src/kinds/dyn-shape.tsx +394 -0
- package/src/kinds/event.tsx +70 -0
- package/src/kinds/function.tsx +53 -0
- package/src/kinds/funnel.tsx +122 -0
- package/src/kinds/gantt.tsx +193 -0
- package/src/kinds/generic.tsx +368 -0
- package/src/kinds/mind-map.tsx +367 -0
- package/src/kinds/mind-radial.tsx +209 -0
- package/src/kinds/node-tone.ts +18 -0
- package/src/kinds/quadrant.tsx +164 -0
- package/src/kinds/row-align.ts +179 -0
- package/src/kinds/shape-basement.tsx +683 -0
- package/src/kinds/shape-blockchain.tsx +750 -0
- package/src/kinds/shape-commerce.tsx +553 -0
- package/src/kinds/shape-finance.tsx +706 -0
- package/src/kinds/shape-hardware.tsx +862 -0
- package/src/kinds/shape-people.tsx +439 -0
- package/src/kinds/shape-region.tsx +383 -0
- package/src/kinds/shape-software.tsx +859 -0
- package/src/kinds/storage.tsx +180 -0
- package/src/kinds/text-width.ts +73 -0
- package/src/kinds/tree.tsx +275 -0
- package/src/kinds/user-journey.tsx +240 -0
- package/src/label-text.ts +71 -0
- package/src/layout/clearance-constants.ts +47 -0
- package/src/layout/collisions.ts +2078 -0
- package/src/layout/edges.ts +2498 -0
- package/src/layout/footer-shape.ts +97 -0
- package/src/layout/geometry.ts +135 -0
- package/src/layout/label-shift.ts +28 -0
- package/src/layout/lanes.ts +328 -0
- package/src/layout/nodes.ts +190 -0
- package/src/layout/predict-bbox.ts +76 -0
- package/src/layout/px-projection.ts +176 -0
- package/src/layout/spec.ts +872 -0
- package/src/layout/text-width.ts +133 -0
- package/src/layout/tokens.ts +181 -0
- package/src/layout/viewbox.ts +47 -0
- package/src/layout-with-validation.ts +175 -0
- package/src/layout.ts +381 -0
- package/src/presets.ts +2108 -0
- package/src/reactive/batch.ts +48 -0
- package/src/reactive/computed.ts +85 -0
- package/src/reactive/effect.ts +145 -0
- package/src/reactive/index.ts +6 -0
- package/src/reactive/internal.ts +109 -0
- package/src/reactive/signal.ts +113 -0
- package/src/render/edges.tsx +318 -0
- package/src/render/header.tsx +146 -0
- package/src/render/interactive-panel.tsx +9620 -0
- package/src/render/nodes.tsx +319 -0
- package/src/render/stage.tsx +377 -0
- package/src/render/tone.ts +64 -0
- package/src/render/utils.ts +238 -0
- package/src/render.tsx +221 -0
- package/src/scroll-trigger/index.ts +109 -0
- package/src/scroll-trigger/progress.ts +49 -0
- package/src/thumbnail.tsx +114 -0
- package/src/types.ts +2371 -0
- package/src/validate.ts +268 -0
- package/src/visual-validate.ts +4381 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { JSX } from "react";
|
|
2
|
+
import type { LaidNode } from "../types";
|
|
3
|
+
import { nodeAccent } from "./node-tone";
|
|
4
|
+
import { titlePlacement } from "./compact-title";
|
|
5
|
+
import {
|
|
6
|
+
ROW_PITCH_STORAGE,
|
|
7
|
+
ROW_TOP_STORAGE,
|
|
8
|
+
STORAGE_ROW_DIVIDER_Y,
|
|
9
|
+
STORAGE_TITLE_BASELINE,
|
|
10
|
+
rowBaselineY,
|
|
11
|
+
rowGlyphDepth,
|
|
12
|
+
} from "../layout/spec";
|
|
13
|
+
import {
|
|
14
|
+
ROW_PAD_X_PX,
|
|
15
|
+
STORAGE_CHAR_ADVANCE_PX,
|
|
16
|
+
STORAGE_COL_GAP_PX,
|
|
17
|
+
displayWidth,
|
|
18
|
+
splitRow,
|
|
19
|
+
} from "./row-align";
|
|
20
|
+
|
|
21
|
+
export function StorageNode({
|
|
22
|
+
node,
|
|
23
|
+
active,
|
|
24
|
+
rows,
|
|
25
|
+
highlightRows,
|
|
26
|
+
}: {
|
|
27
|
+
node: LaidNode;
|
|
28
|
+
active: boolean;
|
|
29
|
+
rows: string[];
|
|
30
|
+
highlightRows: boolean;
|
|
31
|
+
}): JSX.Element {
|
|
32
|
+
const accent = nodeAccent(node, "var(--cdl-tone-accent, #2d6a8f)");
|
|
33
|
+
const x = node.cx - node.w / 2;
|
|
34
|
+
const y = node.cy - node.h / 2;
|
|
35
|
+
// 小型の箱では名前を中央に置く。 固定の位置のままだと下端をまたぐ (#416)
|
|
36
|
+
const 名前 = titlePlacement(node, { x: 26, y: STORAGE_TITLE_BASELINE, size: 22 }, { hasEyebrow: Boolean(node.eyebrow) });
|
|
37
|
+
// 全 row を delimiter (`:` / ` = ` / ` → ` / ` -> `) で left / right に分割し、
|
|
38
|
+
// left 列 max 文字数を算出 → 右列の固定 x 位置を決める (DB table / UML class box 風)。
|
|
39
|
+
// 結果として全 row の右列開始 x が揃い、 区切り線がガタつかなくなる。
|
|
40
|
+
const splits = rows.map((r) => splitRow(r));
|
|
41
|
+
const leftMaxWidth = splits.reduce((m, s) => Math.max(m, displayWidth(s.left)), 0);
|
|
42
|
+
// 列の X は実測の送り幅で決める。 近似 (`ROW_CHAR_WIDTH_PX`) は実測に届かず、 その差が
|
|
43
|
+
// 列の間の余白を食う = 縦線と文字が触れる
|
|
44
|
+
const rightColX = ROW_PAD_X_PX + leftMaxWidth * STORAGE_CHAR_ADVANCE_PX + STORAGE_COL_GAP_PX;
|
|
45
|
+
// 列に分かれているかは delimiter の有無で見る。 右列の中身で見ると、 値が空になった行しか
|
|
46
|
+
// 無い図で列が消える (`status: {signal}` が空を返す等)
|
|
47
|
+
const hasRightColumn = splits.some((s) => s.split);
|
|
48
|
+
// 列を左右に分ける縦線の X = 左列の右端と右列の左端の中間。
|
|
49
|
+
// どちらも実測の送り幅で出すので、 線の左右に `STORAGE_COL_GAP_PX / 2` (= 16) ずつ残る
|
|
50
|
+
const leftEndDrawn = ROW_PAD_X_PX + leftMaxWidth * STORAGE_CHAR_ADVANCE_PX;
|
|
51
|
+
const columnDividerX = (leftEndDrawn + rightColX) / 2;
|
|
52
|
+
// 箱の幅は行の長さから決まるとは限らない。 著者が `w` を書いた図、 絶対座標で置いた図、
|
|
53
|
+
// 実行時に幅を変える図では、 行に必要な幅より狭い箱になる。 その時は線が箱の外に出るので
|
|
54
|
+
// 引かない (中の文字が既に溢れており、 線を引いても読みやすくならない)
|
|
55
|
+
const columnDividerFits = columnDividerX <= node.w - ROW_PAD_X_PX;
|
|
56
|
+
// 縦線の下端 = 最終行の字形の下端。 箱の下端まで伸ばすと、 行が終わった後の余白に線だけが残る。
|
|
57
|
+
// 同時に箱より下へも出さない = 行数と箱の高さは独立に決まるため、 行が多い図や高さを明示した
|
|
58
|
+
// 図では最終行が箱の外に来る
|
|
59
|
+
const lastRowBottom = Math.min(
|
|
60
|
+
ROW_TOP_STORAGE + (rows.length - 1) * ROW_PITCH_STORAGE + (rowGlyphDepth("storage") ?? 0),
|
|
61
|
+
node.h - ROW_PAD_X_PX,
|
|
62
|
+
);
|
|
63
|
+
return (
|
|
64
|
+
<g transform={`translate(${x} ${y})`}>
|
|
65
|
+
<rect
|
|
66
|
+
data-cdl-role="node-body"
|
|
67
|
+
x={0}
|
|
68
|
+
y={0}
|
|
69
|
+
width={node.w}
|
|
70
|
+
height={node.h}
|
|
71
|
+
rx={16}
|
|
72
|
+
fill="var(--cdl-node-fill, #ffffff)"
|
|
73
|
+
stroke={active ? accent : "var(--cdl-divider, #d4d4d8)"}
|
|
74
|
+
strokeWidth={active ? 3 : 1.25}
|
|
75
|
+
/>
|
|
76
|
+
{node.eyebrow && (
|
|
77
|
+
<text x={26} y={38} fontSize={18} fontWeight={700} letterSpacing={2} fill={accent}>
|
|
78
|
+
{node.eyebrow}
|
|
79
|
+
</text>
|
|
80
|
+
)}
|
|
81
|
+
<text
|
|
82
|
+
data-cdl-role="node-label"
|
|
83
|
+
x={名前.x}
|
|
84
|
+
y={名前.y}
|
|
85
|
+
fontSize={名前.size}
|
|
86
|
+
fontWeight={700}
|
|
87
|
+
fill="var(--cdl-text, #1a1f2a)"
|
|
88
|
+
textAnchor={名前.anchor}
|
|
89
|
+
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
|
90
|
+
>
|
|
91
|
+
{node.title}
|
|
92
|
+
</text>
|
|
93
|
+
{/*
|
|
94
|
+
表の区切り線 2 本 (`#419`)。
|
|
95
|
+
|
|
96
|
+
横線 = 名前と列の間。 縦線 = 左列 (名前) と右列 (値) の間。 名前の下だけに線を引いても
|
|
97
|
+
値がどこから始まるかは読み取れないため、 2 本で 1 組にする。
|
|
98
|
+
|
|
99
|
+
位置 = 横線は名前と 1 行目の中間 (`STORAGE_ROW_DIVIDER_Y`)、 縦線は左列の右端と
|
|
100
|
+
右列の左端の中間。 どちらも隣り合う 2 つの中間に置く。
|
|
101
|
+
|
|
102
|
+
引かない場合 = 列が 0 件なら横線を引かない (下に何も無いのに線だけが残る)。
|
|
103
|
+
右列を持つ行が 1 つも無いなら縦線を引かない (分ける相手が居ない)。
|
|
104
|
+
|
|
105
|
+
色は `--cdl-row-divider` を独立して持つ。 枠線と同じ `--cdl-divider` を借りていたが、
|
|
106
|
+
consumer はあれを **枠線の色** として上書きする (`transparent` にして枠を消す等)
|
|
107
|
+
ため、 区切り線まで一緒に消える。 役割が違うので変数を分ける。
|
|
108
|
+
|
|
109
|
+
既定値は consumer が何も指定しない時に、 既定の箱の塗り (`--cdl-node-fill` の
|
|
110
|
+
`#ffffff`) に対して 4:1 前後の対比になる灰色。 変更前は `#d4d4d8` を 0.35 の
|
|
111
|
+
不透明度で描いており、 実測の対比は 1.08:1 で見えなかった。
|
|
112
|
+
|
|
113
|
+
`data-cdl-role` を付けるのは、 consumer が theme ごとに色を当てられるようにするため。
|
|
114
|
+
役割が無い要素には CSS が届かず、 変数を上書きする経路しか残らない。
|
|
115
|
+
*/}
|
|
116
|
+
{rows.length > 0 && (
|
|
117
|
+
<line
|
|
118
|
+
data-cdl-role="node-row-divider"
|
|
119
|
+
x1={26}
|
|
120
|
+
y1={STORAGE_ROW_DIVIDER_Y}
|
|
121
|
+
x2={node.w - 26}
|
|
122
|
+
y2={STORAGE_ROW_DIVIDER_Y}
|
|
123
|
+
stroke="var(--cdl-row-divider, #7e848e)"
|
|
124
|
+
strokeWidth={1.25}
|
|
125
|
+
/>
|
|
126
|
+
)}
|
|
127
|
+
{rows.length > 0 && hasRightColumn && columnDividerFits && (
|
|
128
|
+
<line
|
|
129
|
+
data-cdl-role="node-column-divider"
|
|
130
|
+
x1={columnDividerX}
|
|
131
|
+
y1={STORAGE_ROW_DIVIDER_Y}
|
|
132
|
+
x2={columnDividerX}
|
|
133
|
+
y2={lastRowBottom}
|
|
134
|
+
stroke="var(--cdl-row-divider, #7e848e)"
|
|
135
|
+
strokeWidth={1.25}
|
|
136
|
+
/>
|
|
137
|
+
)}
|
|
138
|
+
{splits.map((s, idx) => {
|
|
139
|
+
// key (label) と value (valueRaw) の font-size を揃える (typography 均衡)。
|
|
140
|
+
// 旧実装は 17 / 30 と約 1.8 倍差があり ER demo や storage row で不均衡が目立った。
|
|
141
|
+
// 左列は固定 x=26、 右列は全 row 共通の rightColX (= 左列 max 幅 + gap) で揃える。
|
|
142
|
+
// 右列を「左 anchor」 で配置する点が重要 (旧実装は node.w-26 への右 anchor で、 row 毎の
|
|
143
|
+
// 値長差により視覚的に区切り線が不揃いに見える問題があった)。
|
|
144
|
+
return (
|
|
145
|
+
<g key={idx} transform={`translate(0 ${rowBaselineY("storage", idx)})`}>
|
|
146
|
+
{/*
|
|
147
|
+
左列も等幅で描く。 列の X は「文字数 × 1 文字の送り幅」 で決めており
|
|
148
|
+
(`STORAGE_CHAR_ADVANCE_PX`)、 その計算は等幅を前提にしている。 左列だけ字幅の違う
|
|
149
|
+
書体で描くと計算と実寸がずれ、 縦線が左列の字形を横切る (`name` で計算 56 /
|
|
150
|
+
実寸 69.6、 実測)。
|
|
151
|
+
*/}
|
|
152
|
+
<text
|
|
153
|
+
data-cdl-role="node-row"
|
|
154
|
+
data-cdl-row-index={idx}
|
|
155
|
+
x={ROW_PAD_X_PX}
|
|
156
|
+
fontSize={26}
|
|
157
|
+
fontWeight={600}
|
|
158
|
+
fill="var(--cdl-text, #1a1f2a)"
|
|
159
|
+
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
|
160
|
+
>
|
|
161
|
+
{s.left}
|
|
162
|
+
</text>
|
|
163
|
+
<text
|
|
164
|
+
data-cdl-role="node-row"
|
|
165
|
+
data-cdl-row-index={idx}
|
|
166
|
+
x={rightColX}
|
|
167
|
+
fontSize={26}
|
|
168
|
+
fontWeight={highlightRows ? 700 : 600}
|
|
169
|
+
textAnchor="start"
|
|
170
|
+
fill={highlightRows ? accent : "var(--cdl-text, #1a1f2a)"}
|
|
171
|
+
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
|
172
|
+
>
|
|
173
|
+
{s.right}
|
|
174
|
+
</text>
|
|
175
|
+
</g>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
</g>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 字の塊の横幅の見積り。
|
|
3
|
+
*
|
|
4
|
+
* **JSX を持たない module に置く**。 絵を描く側 (`shape-region.tsx`) と行を並べる側
|
|
5
|
+
* (`row-align.ts`) の両方が使うが、 前者から取ると React まで引き込まれる。 測定の検査で
|
|
6
|
+
* 読込みの時間が計測に乗り、 図の走査時間の予算 (100ms) を超えた (実測 = 226ms)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 字 1 つが字の大きさに対して占める横幅。
|
|
11
|
+
*
|
|
12
|
+
* **一律の係数では最悪幅を抑えられない**。 平均で置くと `W` の並びで 3 分の 2 しか見ないため、
|
|
13
|
+
* `W` を 20 個並べた名前が、 帯に入れたつもりで 86 はみ出した。 字を階級に分ける。
|
|
14
|
+
*
|
|
15
|
+
* 値は呼び出し側 (dragon) のブラウザで `canvas` の `measureText` を 100px で当てて測った実測。
|
|
16
|
+
* 書体は `"Inter Variable", Inter, -apple-system, "Segoe UI", sans-serif` (dragon の
|
|
17
|
+
* `cdl-theme.css` が `--d-body` を `!important` で当てるため、 cdl 側の指定に関わらずこうなる)。
|
|
18
|
+
*
|
|
19
|
+
* | 階級 | 字 | 係数 | その階級の実測最大 |
|
|
20
|
+
* |---|---|---|---|
|
|
21
|
+
* | 細い | `i` `j` `l` `I` `.` `,` `:` `;` `'` `!` と空白 | 0.37 | 0.343 (`;`) |
|
|
22
|
+
* | 広い | `W` `@` `%` `M` `m` | 1.04 | 1.038 (`W`) |
|
|
23
|
+
* | その他の ASCII | | 0.86 | 0.850 (`w`) |
|
|
24
|
+
* | ASCII の外 | **分類しない** | 1.04 | 1.000 (和文) |
|
|
25
|
+
*
|
|
26
|
+
* **各階級の実測最大を丸め上げる**。 見積りが実物より狭いと、 収めたつもりの名前が箱から出る =
|
|
27
|
+
* 狭い側に外すと直っていないのと同じ。 太字 (700) の値で取れば並 (400) も覆う (並の英数の最大は
|
|
28
|
+
* 0.985)。
|
|
29
|
+
*
|
|
30
|
+
* ## ASCII の外を分類しない理由
|
|
31
|
+
*
|
|
32
|
+
* 「どの字が全角か」 を持つと、 **抜けが過小見積りの向きに出る**。 符号位置の閾値で切れば
|
|
33
|
+
* Latin-1 と結合文字と私用領域が混ざり、 範囲で書けば端点に別の字が紛れ、 script や区画で書けば
|
|
34
|
+
* カタカナ拡張や CJK 互換が落ちる。 3 通り試して 3 通りとも別の抜け方をした。
|
|
35
|
+
*
|
|
36
|
+
* 分類をやめて **ASCII の外は一律で最大の係数 (1.04)** にすると、 抜けようがなくなる。 和文の
|
|
37
|
+
* 実測は 1.000 なので 4% 広く見積もるだけで済み、 半角カナ (約 0.5) や外字も安全側に倒れる。
|
|
38
|
+
* 代償は和文の名前が 4% 余分に縮むことだけ。
|
|
39
|
+
*
|
|
40
|
+
* ASCII の内側は分類を残す。 ここは `W` 1.038 と `;` 0.343 で 3 倍開いており、 最大に寄せると
|
|
41
|
+
* 普通の名前が 1.5 倍の見積りになって過剰に縮む。
|
|
42
|
+
*
|
|
43
|
+
* 等幅の書体なら全字 0.62 で足りるが、 `shape-` はどれも `font-family` を指定せず継承した
|
|
44
|
+
* 比例書体で描くため、 等幅の値は使わない。
|
|
45
|
+
*/
|
|
46
|
+
const WIDE_RATIO = 1.04;
|
|
47
|
+
const NARROW_RATIO = 0.37;
|
|
48
|
+
const ASCII_RATIO = 0.86;
|
|
49
|
+
|
|
50
|
+
/** 広い字と細い字。 どちらも ASCII なので、 ASCII の外がここに一致することはない。 */
|
|
51
|
+
const WIDE_CHARS = "W@%Mm";
|
|
52
|
+
const NARROW_CHARS = "ijlI.,:;'! ";
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 字の塊の横幅の見積り。 jsdom も SVG も描く前に実寸を返さないので、 字数から出す。
|
|
57
|
+
*
|
|
58
|
+
* `字送り` (`letter-spacing`) は字ごとに加わるため字数を掛ける。 最後の 1 字ぶんは実装に
|
|
59
|
+
* よって付かないが、 広い側に外す方を採る。
|
|
60
|
+
*/
|
|
61
|
+
export function textWidth(s: string, size: number, 字送り = 0): number {
|
|
62
|
+
let 幅 = 0;
|
|
63
|
+
let 字数 = 0;
|
|
64
|
+
for (const c of s) {
|
|
65
|
+
if (NARROW_CHARS.includes(c)) 幅 += NARROW_RATIO;
|
|
66
|
+
else if (WIDE_CHARS.includes(c)) 幅 += WIDE_RATIO;
|
|
67
|
+
else if ((c.codePointAt(0) ?? 0) < 128) 幅 += ASCII_RATIO;
|
|
68
|
+
// ASCII の外は分類しない。 最大の係数で見積もれば必ず実物以上になる
|
|
69
|
+
else 幅 += WIDE_RATIO;
|
|
70
|
+
字数 += 1;
|
|
71
|
+
}
|
|
72
|
+
return 幅 * size + 字送り * 字数;
|
|
73
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type { JSX } from "react";
|
|
2
|
+
import type { LaidNode, TreeNodePayload } from "../types";
|
|
3
|
+
import { TONE } from "../render/tone";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* tree-hierarchy kind ... 1 node に tree node 配列 (treeData) を保持し、
|
|
7
|
+
* 縦階層 hierarchical layout で SVG 描画する。
|
|
8
|
+
*
|
|
9
|
+
* デザイン方針 (batch3):
|
|
10
|
+
* - depth 0 (root) は accent gradient rounded rect + top border pill
|
|
11
|
+
* - depth 1+ は tone 別 accent の left-border + 白 fill card
|
|
12
|
+
* - parent-child edge は elbow line + 中間 join dot で「分岐点」 を強調
|
|
13
|
+
*/
|
|
14
|
+
export function TreeNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
|
|
15
|
+
const x0 = node.cx - node.w / 2;
|
|
16
|
+
const y0 = node.cy - node.h / 2;
|
|
17
|
+
const treeNodes = node.treeData ?? [];
|
|
18
|
+
const gradientId = `tree-root-grad-${node.id}`;
|
|
19
|
+
|
|
20
|
+
const PAD_TOP = 40;
|
|
21
|
+
const PAD_RIGHT = 24;
|
|
22
|
+
const PAD_BOTTOM = 40;
|
|
23
|
+
const PAD_LEFT = 24;
|
|
24
|
+
const canvasW = node.w - PAD_LEFT - PAD_RIGHT;
|
|
25
|
+
const canvasH = node.h - PAD_TOP - PAD_BOTTOM;
|
|
26
|
+
|
|
27
|
+
const layout = computeTreeLayout(treeNodes, canvasW, canvasH);
|
|
28
|
+
const depthColors = [TONE.accent, TONE.teal, TONE.success, TONE.warning, TONE.info];
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<g transform={`translate(${x0} ${y0})`}>
|
|
32
|
+
<defs>
|
|
33
|
+
<linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
|
|
34
|
+
<stop offset="0%" stopColor={TONE.accent} />
|
|
35
|
+
<stop offset="100%" stopColor="var(--cdl-tone-accent-deep, #1f4d6e)" />
|
|
36
|
+
</linearGradient>
|
|
37
|
+
</defs>
|
|
38
|
+
|
|
39
|
+
<rect
|
|
40
|
+
data-cdl-role="node-body"
|
|
41
|
+
x={0}
|
|
42
|
+
y={0}
|
|
43
|
+
width={node.w}
|
|
44
|
+
height={node.h}
|
|
45
|
+
rx={16}
|
|
46
|
+
fill="var(--cdl-node-fill, #ffffff)"
|
|
47
|
+
stroke={active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)"}
|
|
48
|
+
strokeWidth={active ? 3 : 1.25}
|
|
49
|
+
/>
|
|
50
|
+
|
|
51
|
+
{/*
|
|
52
|
+
edge は node と同じ座標系で描く。 node 側は `translate(PAD_LEFT + …, PAD_TOP + …)` を
|
|
53
|
+
個別に掛けているため、 edge にも同じ padding を足さないと線が node から (-24, -40) ずれる。
|
|
54
|
+
*/}
|
|
55
|
+
{layout.edges.map((e, i) => {
|
|
56
|
+
const x1 = PAD_LEFT + e.x1;
|
|
57
|
+
const y1 = PAD_TOP + e.y1;
|
|
58
|
+
const x2 = PAD_LEFT + e.x2;
|
|
59
|
+
const y2 = PAD_TOP + e.y2;
|
|
60
|
+
const midY = (y1 + y2) / 2;
|
|
61
|
+
const path = `M ${x1} ${y1} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2}`;
|
|
62
|
+
return (
|
|
63
|
+
<g key={`edge-${i}`}>
|
|
64
|
+
<path
|
|
65
|
+
data-cdl-role="tree-edge"
|
|
66
|
+
d={path}
|
|
67
|
+
fill="none"
|
|
68
|
+
stroke={TONE.accent}
|
|
69
|
+
strokeOpacity={0.55}
|
|
70
|
+
strokeWidth={1.5}
|
|
71
|
+
strokeLinejoin="round"
|
|
72
|
+
/>
|
|
73
|
+
<circle cx={x1} cy={midY} r={2.5} fill={TONE.accent} fillOpacity={0.75} />
|
|
74
|
+
</g>
|
|
75
|
+
);
|
|
76
|
+
})}
|
|
77
|
+
|
|
78
|
+
{layout.nodes.map((n) => {
|
|
79
|
+
const accent = depthColors[Math.min(n.depth, depthColors.length - 1)] ?? depthColors[0]!;
|
|
80
|
+
const isRoot = n.depth === 0;
|
|
81
|
+
return (
|
|
82
|
+
<g key={`n-${n.id}`} transform={`translate(${PAD_LEFT + n.x - n.w / 2} ${PAD_TOP + n.y - n.h / 2})`}>
|
|
83
|
+
{isRoot ? (
|
|
84
|
+
<>
|
|
85
|
+
<rect
|
|
86
|
+
data-cdl-role="tree-node"
|
|
87
|
+
x={0}
|
|
88
|
+
y={0}
|
|
89
|
+
width={n.w}
|
|
90
|
+
height={n.h}
|
|
91
|
+
rx={12}
|
|
92
|
+
fill={`url(#${gradientId})`}
|
|
93
|
+
/>
|
|
94
|
+
<text
|
|
95
|
+
x={n.w / 2}
|
|
96
|
+
y={n.h / 2 + 5}
|
|
97
|
+
fontSize={13}
|
|
98
|
+
fontWeight={700}
|
|
99
|
+
textAnchor="middle"
|
|
100
|
+
fill="var(--cdl-text-on-tone, #ffffff)"
|
|
101
|
+
>
|
|
102
|
+
{n.title}
|
|
103
|
+
</text>
|
|
104
|
+
</>
|
|
105
|
+
) : (
|
|
106
|
+
<>
|
|
107
|
+
<rect
|
|
108
|
+
data-cdl-role="tree-node"
|
|
109
|
+
x={0}
|
|
110
|
+
y={0}
|
|
111
|
+
width={n.w}
|
|
112
|
+
height={n.h}
|
|
113
|
+
rx={9}
|
|
114
|
+
fill="var(--cdl-chip-fill, #f4f6fb)"
|
|
115
|
+
stroke={accent}
|
|
116
|
+
strokeWidth={1.5}
|
|
117
|
+
/>
|
|
118
|
+
<rect x={0} y={0} width={4} height={n.h} rx={2} fill={accent} />
|
|
119
|
+
<text
|
|
120
|
+
x={n.w / 2 + 2}
|
|
121
|
+
y={n.h / 2 + 4}
|
|
122
|
+
fontSize={12}
|
|
123
|
+
fontWeight={600}
|
|
124
|
+
textAnchor="middle"
|
|
125
|
+
fill="var(--cdl-chip-text, #1a1f2a)"
|
|
126
|
+
>
|
|
127
|
+
{n.title}
|
|
128
|
+
</text>
|
|
129
|
+
</>
|
|
130
|
+
)}
|
|
131
|
+
</g>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
134
|
+
</g>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type LayoutNode = {
|
|
139
|
+
id: string;
|
|
140
|
+
title: string;
|
|
141
|
+
x: number;
|
|
142
|
+
y: number;
|
|
143
|
+
w: number;
|
|
144
|
+
h: number;
|
|
145
|
+
depth: number;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export function computeTreeLayout(
|
|
149
|
+
treeNodes: TreeNodePayload[],
|
|
150
|
+
canvasW: number,
|
|
151
|
+
canvasH: number,
|
|
152
|
+
): {
|
|
153
|
+
nodes: LayoutNode[];
|
|
154
|
+
edges: Array<{ x1: number; y1: number; x2: number; y2: number }>;
|
|
155
|
+
} {
|
|
156
|
+
// parent が実在しない node は root 扱いにする。 そうしないと root からの再帰で辿れず、
|
|
157
|
+
// node が silent に消える (旧実装は全 node を列挙していたので消えなかった)。
|
|
158
|
+
const ids = new Set(treeNodes.map((n) => n.id));
|
|
159
|
+
const childrenOf = new Map<string, TreeNodePayload[]>();
|
|
160
|
+
for (const n of treeNodes) {
|
|
161
|
+
const p = n.parent && ids.has(n.parent) ? n.parent : "__root__";
|
|
162
|
+
if (!childrenOf.has(p)) childrenOf.set(p, []);
|
|
163
|
+
childrenOf.get(p)!.push(n);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// root から辿れる node を集める (循環参照でも無限ループしないよう visited で打ち切る)。
|
|
167
|
+
const reachable = new Set<string>();
|
|
168
|
+
const walk = (id: string): void => {
|
|
169
|
+
if (reachable.has(id)) return;
|
|
170
|
+
reachable.add(id);
|
|
171
|
+
for (const c of childrenOf.get(id) ?? []) walk(c.id);
|
|
172
|
+
};
|
|
173
|
+
for (const r of childrenOf.get("__root__") ?? []) walk(r.id);
|
|
174
|
+
|
|
175
|
+
// 循環参照で root から辿れない node は、 宣言順で最初に現れたものを root 扱いにして拾う。
|
|
176
|
+
// これで循環に含まれる node も silent に消えない。
|
|
177
|
+
const roots = [...(childrenOf.get("__root__") ?? [])];
|
|
178
|
+
for (const n of treeNodes) {
|
|
179
|
+
if (!reachable.has(n.id)) {
|
|
180
|
+
roots.push(n);
|
|
181
|
+
walk(n.id);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 以下 3 つの再帰は childrenOf を辿るため、 循環参照があると無限ループする。
|
|
186
|
+
// seen で既訪 node を打ち切り、 循環辺の先を子として展開しない (leaf 扱い)。
|
|
187
|
+
const depthBelow = (id: string, seen: Set<string>): number => {
|
|
188
|
+
if (seen.has(id)) return 0;
|
|
189
|
+
seen.add(id);
|
|
190
|
+
const kids = childrenOf.get(id) ?? [];
|
|
191
|
+
if (kids.length === 0) return 0;
|
|
192
|
+
return 1 + Math.max(...kids.map((k) => depthBelow(k.id, seen)));
|
|
193
|
+
};
|
|
194
|
+
const maxDepth = roots.length === 0 ? 0 : Math.max(...roots.map((r) => depthBelow(r.id, new Set())));
|
|
195
|
+
|
|
196
|
+
// 葉の数で横幅を配分する。 親は自分の子の中央に置く。
|
|
197
|
+
// 深さごとに canvas 全幅へ均等配置すると、 親と無関係な位置に子が並んで系統が読めない。
|
|
198
|
+
const leafCount = (id: string, seen: Set<string>): number => {
|
|
199
|
+
if (seen.has(id)) return 1;
|
|
200
|
+
seen.add(id);
|
|
201
|
+
const kids = childrenOf.get(id) ?? [];
|
|
202
|
+
if (kids.length === 0) return 1;
|
|
203
|
+
return kids.reduce((sum, k) => sum + leafCount(k.id, seen), 0);
|
|
204
|
+
};
|
|
205
|
+
const totalLeaves = Math.max(roots.reduce((sum, r) => sum + leafCount(r.id, new Set()), 0), 1);
|
|
206
|
+
|
|
207
|
+
const depthGap = canvasH / Math.max(maxDepth + 1, 1);
|
|
208
|
+
const slotW = canvasW / totalLeaves;
|
|
209
|
+
// box は必ず slot の一定割合に収める。 加算的な floor (min 12 / min 8 等) を置くと、 深い / 多い
|
|
210
|
+
// 入力で floor が slot を超えて隣と重なる。 slot の割合にすれば規模によらず必ず gap が残る。
|
|
211
|
+
// 深い / 多い入力では box が小さくなるが、 位置のはみ出しは末尾の最終スケールが捌く。
|
|
212
|
+
const nodeH = Math.min(38, depthGap * 0.7);
|
|
213
|
+
const nodeW = Math.min(140, slotW * 0.85);
|
|
214
|
+
|
|
215
|
+
const posOf = new Map<string, { x: number; y: number }>();
|
|
216
|
+
const nodes: LayoutNode[] = [];
|
|
217
|
+
const placed = new Set<string>();
|
|
218
|
+
let leafCursor = 0;
|
|
219
|
+
|
|
220
|
+
const place = (n: TreeNodePayload, depth: number): number => {
|
|
221
|
+
placed.add(n.id);
|
|
222
|
+
// 循環辺で既配置の node に戻る子は展開しない (無限ループ防止 + 二重描画防止)。
|
|
223
|
+
const kids = (childrenOf.get(n.id) ?? []).filter((k) => !placed.has(k.id));
|
|
224
|
+
let x: number;
|
|
225
|
+
if (kids.length === 0) {
|
|
226
|
+
x = leafCursor * slotW + slotW / 2;
|
|
227
|
+
leafCursor += 1;
|
|
228
|
+
} else {
|
|
229
|
+
const xs = kids.map((k) => place(k, depth + 1));
|
|
230
|
+
x = (xs[0]! + xs[xs.length - 1]!) / 2;
|
|
231
|
+
}
|
|
232
|
+
const y = depth * depthGap + depthGap / 2;
|
|
233
|
+
posOf.set(n.id, { x, y });
|
|
234
|
+
nodes.push({ id: n.id, title: n.title, x, y, w: nodeW, h: nodeH, depth });
|
|
235
|
+
return x;
|
|
236
|
+
};
|
|
237
|
+
for (const r of roots) if (!placed.has(r.id)) place(r, 0);
|
|
238
|
+
|
|
239
|
+
// 全 node を canvas rect [0, canvasW] × [0, canvasH] に収める最終スケール (Issue #219)。
|
|
240
|
+
// box に最小サイズ floor があるため、 深い / 多い入力で位置の累積が canvas を数 px 超える。
|
|
241
|
+
// floor を下げ続けても次の規模で再発するので、 最後に中心へ向けて uniform scale して必ず収める。
|
|
242
|
+
// uniform scale は相対 gap を保つので非重なりは崩れない。 通常入力は収まりスケール 1。
|
|
243
|
+
const boxCx = canvasW / 2;
|
|
244
|
+
const boxCy = canvasH / 2;
|
|
245
|
+
let extentX = 0;
|
|
246
|
+
let extentY = 0;
|
|
247
|
+
for (const n of nodes) {
|
|
248
|
+
extentX = Math.max(extentX, Math.abs(n.x - boxCx) + n.w / 2);
|
|
249
|
+
extentY = Math.max(extentY, Math.abs(n.y - boxCy) + n.h / 2);
|
|
250
|
+
}
|
|
251
|
+
const scale = Math.min(1, extentX > 0 ? boxCx / extentX : 1, extentY > 0 ? boxCy / extentY : 1);
|
|
252
|
+
if (scale < 1) {
|
|
253
|
+
for (const n of nodes) {
|
|
254
|
+
n.x = boxCx + (n.x - boxCx) * scale;
|
|
255
|
+
n.y = boxCy + (n.y - boxCy) * scale;
|
|
256
|
+
n.w *= scale;
|
|
257
|
+
n.h *= scale;
|
|
258
|
+
posOf.set(n.id, { x: n.x, y: n.y });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const hById = new Map(nodes.map((n) => [n.id, n.h]));
|
|
262
|
+
|
|
263
|
+
const edges: Array<{ x1: number; y1: number; x2: number; y2: number }> = [];
|
|
264
|
+
for (const n of treeNodes) {
|
|
265
|
+
if (!n.parent) continue;
|
|
266
|
+
const parent = posOf.get(n.parent);
|
|
267
|
+
const child = posOf.get(n.id);
|
|
268
|
+
if (!parent || !child) continue;
|
|
269
|
+
const parentH = hById.get(n.parent) ?? nodeH;
|
|
270
|
+
const childH = hById.get(n.id) ?? nodeH;
|
|
271
|
+
edges.push({ x1: parent.x, y1: parent.y + parentH / 2, x2: child.x, y2: child.y - childH / 2 });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { nodes, edges };
|
|
275
|
+
}
|