@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,367 @@
|
|
|
1
|
+
import type { JSX } from "react";
|
|
2
|
+
import type { LaidNode, Tone, MindBranchNode } from "../types";
|
|
3
|
+
import { TONE } from "../render/tone";
|
|
4
|
+
|
|
5
|
+
const MIND_TONES: Tone[] = ["accent", "teal", "success", "warning", "info", "error"];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 箱と箱の間に空ける間隔 (px)。
|
|
9
|
+
*
|
|
10
|
+
* **中心 → 第 1 階層と、 親 → 子で同じ値を使う**。 深さで間隔が変わると、 同じ図の中で
|
|
11
|
+
* 枝の詰まり方が階層ごとに変わって読みにくい。
|
|
12
|
+
*
|
|
13
|
+
* 8 では枝が潰れて線が箱に隠れ、 孫が親と繋がって見えなかった (実測 = 中心 → 第 1 階層が
|
|
14
|
+
* 24px に対し 8px)。 一方で間隔を広げた分だけ箱に使える幅が減るので、 深い図では箱が
|
|
15
|
+
* 読めない幅まで縮む (実測 = 640x400 の実寸で深さ 5 の箱が 2.5px)。
|
|
16
|
+
*
|
|
17
|
+
* `#430` まで同じ 24 を 2 箇所に直書きしており、 片方だけ変えても誰も気付かなかった。
|
|
18
|
+
*/
|
|
19
|
+
export const MIND_BOX_GAP = 24;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* mind-map kind ... 1 node に mindData (rootId / rootTitle / branches) を保持し、
|
|
23
|
+
* 中心 root + 放射 branch を SVG 描画する。
|
|
24
|
+
* layout = 中心 root、 その第 1 階層 branch を左右振り分けの水平放射で配置、
|
|
25
|
+
* 深い階層は parent 方向へ扇状展開。 depth 別 tone を rotate。
|
|
26
|
+
* curved edge = bezier で親子を接続、 node は rounded rect で読みやすさ優先。
|
|
27
|
+
*/
|
|
28
|
+
export function MindMapNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
|
|
29
|
+
const x0 = node.cx - node.w / 2;
|
|
30
|
+
const y0 = node.cy - node.h / 2;
|
|
31
|
+
const mindData = node.mindData;
|
|
32
|
+
if (!mindData) return <g transform={`translate(${x0} ${y0})`} />;
|
|
33
|
+
|
|
34
|
+
const PAD = 40;
|
|
35
|
+
const canvasCx = node.w / 2;
|
|
36
|
+
const canvasCy = node.h / 2;
|
|
37
|
+
const canvasW = node.w - PAD * 2;
|
|
38
|
+
const canvasH = node.h - PAD * 2;
|
|
39
|
+
|
|
40
|
+
const layout = computeMindMapLayout(mindData, canvasCx, canvasCy, canvasW, canvasH);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<g transform={`translate(${x0} ${y0})`}>
|
|
44
|
+
<rect
|
|
45
|
+
data-cdl-role="node-body"
|
|
46
|
+
x={0}
|
|
47
|
+
y={0}
|
|
48
|
+
width={node.w}
|
|
49
|
+
height={node.h}
|
|
50
|
+
rx={16}
|
|
51
|
+
fill="var(--cdl-node-fill, #ffffff)"
|
|
52
|
+
stroke={active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)"}
|
|
53
|
+
strokeWidth={active ? 3 : 1.25}
|
|
54
|
+
/>
|
|
55
|
+
|
|
56
|
+
{layout.edges.map((e, i) => (
|
|
57
|
+
<path
|
|
58
|
+
key={`edge-${i}`}
|
|
59
|
+
data-cdl-role="mind-edge"
|
|
60
|
+
d={e.d}
|
|
61
|
+
fill="none"
|
|
62
|
+
stroke={toneColor(e.tone)}
|
|
63
|
+
strokeWidth={2.5}
|
|
64
|
+
strokeOpacity={0.55}
|
|
65
|
+
strokeLinecap="round"
|
|
66
|
+
/>
|
|
67
|
+
))}
|
|
68
|
+
|
|
69
|
+
{layout.nodes.map((n) => (
|
|
70
|
+
<g key={`n-${n.id}`}>
|
|
71
|
+
<rect
|
|
72
|
+
x={n.x - n.w / 2}
|
|
73
|
+
y={n.y - n.h / 2}
|
|
74
|
+
width={n.w}
|
|
75
|
+
height={n.h}
|
|
76
|
+
rx={n.isRoot ? 22 : 10}
|
|
77
|
+
fill={n.isRoot ? toneColor(n.tone ?? "accent") : "var(--cdl-chip-fill, #f4f6fb)"}
|
|
78
|
+
stroke={n.isRoot ? toneColor(n.tone ?? "accent") : toneColor(n.tone)}
|
|
79
|
+
strokeWidth={n.isRoot ? 2 : 1.75}
|
|
80
|
+
/>
|
|
81
|
+
<text
|
|
82
|
+
x={n.x}
|
|
83
|
+
y={n.y + 5}
|
|
84
|
+
fontSize={n.isRoot ? 15 : 13}
|
|
85
|
+
fontWeight={n.isRoot ? 700 : 600}
|
|
86
|
+
textAnchor="middle"
|
|
87
|
+
fill={n.isRoot ? "var(--cdl-text-on-tone, #ffffff)" : "var(--cdl-chip-text, #1a1f2a)"}
|
|
88
|
+
>
|
|
89
|
+
{n.title}
|
|
90
|
+
</text>
|
|
91
|
+
</g>
|
|
92
|
+
))}
|
|
93
|
+
</g>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type LayoutNode = {
|
|
98
|
+
id: string;
|
|
99
|
+
title: string;
|
|
100
|
+
x: number;
|
|
101
|
+
y: number;
|
|
102
|
+
w: number;
|
|
103
|
+
h: number;
|
|
104
|
+
tone?: Tone;
|
|
105
|
+
isRoot: boolean;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type LayoutEdge = {
|
|
109
|
+
d: string;
|
|
110
|
+
tone?: Tone;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function computeMindMapLayout(
|
|
114
|
+
mindData: { rootId: string; rootTitle: string; branches: MindBranchNode[] },
|
|
115
|
+
cx: number,
|
|
116
|
+
cy: number,
|
|
117
|
+
canvasW: number,
|
|
118
|
+
canvasH: number,
|
|
119
|
+
): { nodes: LayoutNode[]; edges: LayoutEdge[] } {
|
|
120
|
+
const nodes: LayoutNode[] = [];
|
|
121
|
+
const edges: LayoutEdge[] = [];
|
|
122
|
+
|
|
123
|
+
const rootW = 120;
|
|
124
|
+
const rootH = 52;
|
|
125
|
+
nodes.push({
|
|
126
|
+
id: mindData.rootId,
|
|
127
|
+
title: mindData.rootTitle,
|
|
128
|
+
x: cx,
|
|
129
|
+
y: cy,
|
|
130
|
+
w: rootW,
|
|
131
|
+
h: rootH,
|
|
132
|
+
isRoot: true,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const childrenOf = new Map<string, MindBranchNode[]>();
|
|
136
|
+
for (const br of mindData.branches) {
|
|
137
|
+
if (!childrenOf.has(br.parent)) childrenOf.set(br.parent, []);
|
|
138
|
+
childrenOf.get(br.parent)!.push(br);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const nodePos = new Map<string, { x: number; y: number }>();
|
|
142
|
+
const nodeSide = new Map<string, "left" | "right">();
|
|
143
|
+
const nodeTone = new Map<string, Tone>();
|
|
144
|
+
/** 親の辺から edge を出すために、 node ごとの半幅を覚えておく。 */
|
|
145
|
+
const nodeHalfW = new Map<string, number>();
|
|
146
|
+
nodePos.set(mindData.rootId, { x: cx, y: cy });
|
|
147
|
+
nodeHalfW.set(mindData.rootId, rootW / 2);
|
|
148
|
+
|
|
149
|
+
// root から辿れない branch (parent 不整合 / 循環) を first-level に拾い上げて silent 消失を防ぐ。
|
|
150
|
+
// tree.tsx と同じ reachable / roots パターン。 循環は seen で打ち切る。
|
|
151
|
+
const reachable = new Set<string>([mindData.rootId]);
|
|
152
|
+
const walk = (id: string): void => {
|
|
153
|
+
for (const c of childrenOf.get(id) ?? []) {
|
|
154
|
+
if (!reachable.has(c.id)) {
|
|
155
|
+
reachable.add(c.id);
|
|
156
|
+
walk(c.id);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
walk(mindData.rootId);
|
|
161
|
+
const firstLevel = [...(childrenOf.get(mindData.rootId) ?? [])];
|
|
162
|
+
for (const br of mindData.branches) {
|
|
163
|
+
if (!reachable.has(br.id)) {
|
|
164
|
+
firstLevel.push(br);
|
|
165
|
+
reachable.add(br.id);
|
|
166
|
+
walk(br.id);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const firstCount = firstLevel.length;
|
|
170
|
+
const leftCount = Math.ceil(firstCount / 2);
|
|
171
|
+
const branchW = 130;
|
|
172
|
+
const branchH = 38;
|
|
173
|
+
const subBranchW = 100;
|
|
174
|
+
const subBranchH = 30;
|
|
175
|
+
|
|
176
|
+
// childrenOf は循環し得るので、 全再帰に seen 打ち切りを入れる。
|
|
177
|
+
|
|
178
|
+
// subtree の最大深さ (branch = 0)。
|
|
179
|
+
const subDepthOf = (id: string, seen: Set<string>): number => {
|
|
180
|
+
if (seen.has(id)) return 0;
|
|
181
|
+
seen.add(id);
|
|
182
|
+
const kids = childrenOf.get(id) ?? [];
|
|
183
|
+
if (kids.length === 0) return 0;
|
|
184
|
+
return 1 + Math.max(...kids.map((k) => subDepthOf(k.id, seen)));
|
|
185
|
+
};
|
|
186
|
+
// subtree の葉数。 兄弟に縦レーンを配分するのに使う。
|
|
187
|
+
const leafCountOf = (id: string, seen: Set<string>): number => {
|
|
188
|
+
if (seen.has(id)) return 1;
|
|
189
|
+
seen.add(id);
|
|
190
|
+
const kids = childrenOf.get(id) ?? [];
|
|
191
|
+
if (kids.length === 0) return 1;
|
|
192
|
+
return kids.reduce((sum, k) => sum + leafCountOf(k.id, seen), 0);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const maxSubDepth = firstLevel.length > 0 ? Math.max(0, ...firstLevel.map((br) => subDepthOf(br.id, new Set()))) : 0;
|
|
196
|
+
|
|
197
|
+
// branch を root からどれだけ離すか。 深いネストがあるほど sub 世代のぶん内側に寄せる。
|
|
198
|
+
const minLayer1XOffset = rootW / 2 + branchW / 2 + MIND_BOX_GAP;
|
|
199
|
+
const preferredSubDx = subBranchW + 20;
|
|
200
|
+
const reserve = maxSubDepth > 0 ? branchW / 2 + maxSubDepth * preferredSubDx : 0;
|
|
201
|
+
const maxLayer1XOffset = Math.max(minLayer1XOffset, canvasW / 2 - reserve);
|
|
202
|
+
const layer1XOffset = Math.max(minLayer1XOffset, Math.min(canvasW * 0.32, maxLayer1XOffset));
|
|
203
|
+
|
|
204
|
+
// branch の外側に残る横の余白。 これを深さで割り、 sub box もそこに収まる幅に縮める。
|
|
205
|
+
// 単一 chain は同じ縦レーンを共有するので、 横 step が box より広くないと重なる。
|
|
206
|
+
const room = canvasW / 2 - layer1XOffset;
|
|
207
|
+
/** 箱の中の文字が読める最小の幅。 これを割ると文字だけが縮まず箱からはみ出す。 */
|
|
208
|
+
const MIN_SUB_W = 56;
|
|
209
|
+
/** 親と子の箱の間隔として望ましい値。 中心 → 第 1 階層と同じ `MIND_BOX_GAP` を使う。 */
|
|
210
|
+
const PREFERRED_GAP = MIND_BOX_GAP;
|
|
211
|
+
//
|
|
212
|
+
// **箱の幅を先に確保し、 余った分を間隔に回す**。 浅い図では望ましい値になり、 深い図では
|
|
213
|
+
// 従来どおりまで詰まる = 箱が潰れる形は増やさない。
|
|
214
|
+
const gapRoom = maxSubDepth > 0 ? (room - branchW / 2 - maxSubDepth * MIN_SUB_W) / maxSubDepth : 0;
|
|
215
|
+
const gap = Math.max(8, Math.min(PREFERRED_GAP, gapRoom));
|
|
216
|
+
// 最深 box の外端が canvas 端に収まる effSubW を解く。
|
|
217
|
+
// layer1XOffset + branchW/2 + effSubW*maxSubDepth + maxSubDepth*gap <= canvasW/2
|
|
218
|
+
// (step1 = (branchW+effSubW)/2 + gap、 stepN = effSubW + gap、 最深 box 半幅 effSubW/2 込み)
|
|
219
|
+
// 下限は極小 (1)。 大きな floor は深いネストで subOffset の累積を膨らませる。 位置の
|
|
220
|
+
// はみ出しは最終スケール (下部) が捌くので、 ここでは重なり回避に必要な最小だけ残す。
|
|
221
|
+
const effSubW =
|
|
222
|
+
maxSubDepth > 0
|
|
223
|
+
? Math.max(1, Math.min(subBranchW, (room - branchW / 2 - maxSubDepth * gap) / maxSubDepth))
|
|
224
|
+
: subBranchW;
|
|
225
|
+
const step1 = (branchW + effSubW) / 2 + gap; // branch → 第 1 sub
|
|
226
|
+
const stepN = effSubW + gap; // sub → sub
|
|
227
|
+
const subOffset = (subDepth: number): number => (subDepth <= 0 ? 0 : step1 + (subDepth - 1) * stepN);
|
|
228
|
+
|
|
229
|
+
// 縦レーン。 左右それぞれの branch 群の葉数合計でレーンを割る。
|
|
230
|
+
const leftBranches = firstLevel.slice(0, leftCount);
|
|
231
|
+
const rightBranches = firstLevel.slice(leftCount);
|
|
232
|
+
const leavesOfSide = (brs: MindBranchNode[]): number =>
|
|
233
|
+
Math.max(1, brs.reduce((sum, br) => sum + leafCountOf(br.id, new Set()), 0));
|
|
234
|
+
const sideLaneCount = Math.max(leavesOfSide(leftBranches), leavesOfSide(rightBranches));
|
|
235
|
+
const laneBandH = Math.min(canvasH * 0.9, sideLaneCount * (subBranchH + 14));
|
|
236
|
+
const rowGap = sideLaneCount > 1 ? laneBandH / (sideLaneCount - 1) : subBranchH + 14;
|
|
237
|
+
// box 高は必ず rowGap の一定割合に収める。 加算的な floor を置くと、 leaf が多く rowGap が
|
|
238
|
+
// 小さいとき floor が rowGap を超えて縦重なりする。 rowGap の 70% にすれば規模によらず gap が残る。
|
|
239
|
+
const effSubH = Math.min(subBranchH, rowGap * 0.7);
|
|
240
|
+
const effBranchH = Math.min(branchH, rowGap * 0.7);
|
|
241
|
+
// canvas の上下端 (full-node 座標)。 lane はこの内側に収める。
|
|
242
|
+
const laneTop = cy - canvasH / 2 + effSubH / 2;
|
|
243
|
+
const laneBottom = cy + canvasH / 2 - effSubH / 2;
|
|
244
|
+
const laneY = (lane: number): number => {
|
|
245
|
+
if (sideLaneCount <= 1) return cy;
|
|
246
|
+
const y = cy - laneBandH / 2 + lane * rowGap;
|
|
247
|
+
return Math.max(laneTop, Math.min(laneBottom, y));
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// scale 後の root 半幅を使う (最終スケールで root も縮む)。
|
|
251
|
+
const rootEdgeXFor = (isLeft: boolean): number => {
|
|
252
|
+
const half = nodeHalfW.get(mindData.rootId) ?? rootW / 2;
|
|
253
|
+
return cx + (isLeft ? -half : half);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// subtree を再帰配置する。 leaf は次のレーン、 親は子レーンの中央。 x は深さで決まる。
|
|
257
|
+
const placeSubtree = (
|
|
258
|
+
node: MindBranchNode,
|
|
259
|
+
subDepth: number,
|
|
260
|
+
isLeft: boolean,
|
|
261
|
+
tone: Tone | undefined,
|
|
262
|
+
placed: Set<string>,
|
|
263
|
+
laneRef: { cursor: number },
|
|
264
|
+
): number => {
|
|
265
|
+
placed.add(node.id);
|
|
266
|
+
const dirX = isLeft ? -1 : 1;
|
|
267
|
+
const x = cx + dirX * (layer1XOffset + subOffset(subDepth));
|
|
268
|
+
const w = subDepth === 0 ? branchW : effSubW;
|
|
269
|
+
const h = subDepth === 0 ? effBranchH : effSubH;
|
|
270
|
+
const myTone = node.tone ?? tone;
|
|
271
|
+
|
|
272
|
+
const kids = (childrenOf.get(node.id) ?? []).filter((k) => !placed.has(k.id));
|
|
273
|
+
let y: number;
|
|
274
|
+
if (kids.length === 0) {
|
|
275
|
+
y = laneY(laneRef.cursor);
|
|
276
|
+
laneRef.cursor += 1;
|
|
277
|
+
} else {
|
|
278
|
+
const ys = kids.map((k, i) =>
|
|
279
|
+
placeSubtree(k, subDepth + 1, isLeft, myTone ?? MIND_TONES[i % MIND_TONES.length]!, placed, laneRef),
|
|
280
|
+
);
|
|
281
|
+
y = (ys[0]! + ys[ys.length - 1]!) / 2;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
nodes.push({ id: node.id, title: node.title, x, y, w, h, tone: myTone, isRoot: false });
|
|
285
|
+
nodePos.set(node.id, { x, y });
|
|
286
|
+
nodeSide.set(node.id, isLeft ? "left" : "right");
|
|
287
|
+
nodeHalfW.set(node.id, w / 2);
|
|
288
|
+
if (myTone) nodeTone.set(node.id, myTone);
|
|
289
|
+
return y;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const placeSide = (brs: MindBranchNode[], isLeft: boolean, toneBase: number): void => {
|
|
293
|
+
const laneRef = { cursor: 0 };
|
|
294
|
+
const placed = new Set<string>();
|
|
295
|
+
brs.forEach((br, i) => {
|
|
296
|
+
const tone = br.tone ?? MIND_TONES[(toneBase + i) % MIND_TONES.length]!;
|
|
297
|
+
placeSubtree(br, 0, isLeft, tone, placed, laneRef);
|
|
298
|
+
});
|
|
299
|
+
};
|
|
300
|
+
placeSide(leftBranches, true, 0);
|
|
301
|
+
placeSide(rightBranches, false, leftCount);
|
|
302
|
+
|
|
303
|
+
// 全 node を canvas rect に収める最終スケール (Issue #219、 box floor の漸近限界対策)。
|
|
304
|
+
//
|
|
305
|
+
// box に最小サイズ floor があるため、 深さ / leaf 数を上げると位置の累積が canvas を数 px 超える。
|
|
306
|
+
// floor を下げ続けても次の規模で再発するので、 最後に全体を中心へ向けて uniform scale して必ず収める。
|
|
307
|
+
// uniform scale は相対 gap を保つので、 非重なりは崩れない。 通常入力は extent が収まりスケール 1。
|
|
308
|
+
{
|
|
309
|
+
const drawableHalfW = canvasW / 2;
|
|
310
|
+
const drawableHalfH = canvasH / 2;
|
|
311
|
+
let maxExtentX = rootW / 2;
|
|
312
|
+
let maxExtentY = rootH / 2;
|
|
313
|
+
for (const n of nodes) {
|
|
314
|
+
maxExtentX = Math.max(maxExtentX, Math.abs(n.x - cx) + n.w / 2);
|
|
315
|
+
maxExtentY = Math.max(maxExtentY, Math.abs(n.y - cy) + n.h / 2);
|
|
316
|
+
}
|
|
317
|
+
const scale = Math.min(1, drawableHalfW / maxExtentX, drawableHalfH / maxExtentY);
|
|
318
|
+
if (scale < 1) {
|
|
319
|
+
for (const n of nodes) {
|
|
320
|
+
n.x = cx + (n.x - cx) * scale;
|
|
321
|
+
n.y = cy + (n.y - cy) * scale;
|
|
322
|
+
n.w *= scale;
|
|
323
|
+
n.h *= scale;
|
|
324
|
+
nodePos.set(n.id, { x: n.x, y: n.y });
|
|
325
|
+
nodeHalfW.set(n.id, n.w / 2);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// edge = 親の辺 → 子の辺。 root → 第 1 branch は root の辺から。
|
|
331
|
+
for (const br of mindData.branches) {
|
|
332
|
+
const childPos = nodePos.get(br.id);
|
|
333
|
+
if (!childPos) continue;
|
|
334
|
+
const isLeft = nodeSide.get(br.id) === "left";
|
|
335
|
+
const childHalfW = nodeHalfW.get(br.id) ?? subBranchW / 2;
|
|
336
|
+
const tone = nodeTone.get(br.id);
|
|
337
|
+
if (br.parent === mindData.rootId) {
|
|
338
|
+
const startX = rootEdgeXFor(isLeft);
|
|
339
|
+
const endX = childPos.x + (isLeft ? childHalfW : -childHalfW);
|
|
340
|
+
edges.push({ d: makeCurve(startX, cy, endX, childPos.y, isLeft), tone });
|
|
341
|
+
} else {
|
|
342
|
+
const parentPos = nodePos.get(br.parent);
|
|
343
|
+
if (!parentPos) continue;
|
|
344
|
+
const parentHalfW = nodeHalfW.get(br.parent) ?? branchW / 2;
|
|
345
|
+
const startX = parentPos.x + (isLeft ? -parentHalfW : parentHalfW);
|
|
346
|
+
const endX = childPos.x + (isLeft ? childHalfW : -childHalfW);
|
|
347
|
+
edges.push({ d: makeCurve(startX, parentPos.y, endX, childPos.y, isLeft), tone });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { nodes, edges };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function makeCurve(x1: number, y1: number, x2: number, y2: number, _leftBound: boolean): string {
|
|
355
|
+
const midX = (x1 + x2) / 2;
|
|
356
|
+
return `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
function toneColor(tone: Tone | undefined, alpha = 1): string {
|
|
361
|
+
const hex = tone ? TONE[tone] : TONE.accent;
|
|
362
|
+
if (alpha >= 1) return hex;
|
|
363
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
364
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
365
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
366
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
367
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { JSX } from "react";
|
|
2
|
+
import type { LaidNode, Tone, MindBranchNode } from "../types";
|
|
3
|
+
import { TONE } from "../render/tone";
|
|
4
|
+
|
|
5
|
+
const MIND_TONES: Tone[] = ["accent", "teal", "success", "warning", "info", "error"];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* mind-radial kind ... mindMapRadial preset 用、 中心 + 8 方向 (45 度間隔) の
|
|
9
|
+
* 放射状 branch 配置。 mind-map との違いは「第 1 階層を 45 度で 8 分割」 固定。
|
|
10
|
+
*
|
|
11
|
+
* デザイン方針 (batch3):
|
|
12
|
+
* - canvas 背景に subtle radial gradient (中心を強調)
|
|
13
|
+
* - 中心 circle を 2 重 (外周 halo + 本体) + drop shadow で目立たせる
|
|
14
|
+
* - 各 branch は tone-tinted rounded rect + 中心方向へ tapered edge
|
|
15
|
+
* - branch 番号 (方位) を dot annotation で示す
|
|
16
|
+
*/
|
|
17
|
+
export function MindRadialNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
|
|
18
|
+
const x0 = node.cx - node.w / 2;
|
|
19
|
+
const y0 = node.cy - node.h / 2;
|
|
20
|
+
const mindData = node.mindData;
|
|
21
|
+
if (!mindData) return <g transform={`translate(${x0} ${y0})`} />;
|
|
22
|
+
|
|
23
|
+
const canvasCx = node.w / 2;
|
|
24
|
+
const canvasCy = node.h / 2;
|
|
25
|
+
const maxRadius = Math.min(node.w, node.h) / 2 - 100;
|
|
26
|
+
const gradientId = `mind-radial-bg-${node.id}`;
|
|
27
|
+
const shadowId = `mind-radial-shadow-${node.id}`;
|
|
28
|
+
|
|
29
|
+
const layout = computeRadialLayout(mindData, canvasCx, canvasCy, maxRadius);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<g transform={`translate(${x0} ${y0})`}>
|
|
33
|
+
<defs>
|
|
34
|
+
<radialGradient id={gradientId} cx="50%" cy="50%" r="60%">
|
|
35
|
+
<stop offset="0%" stopColor="var(--cdl-tone-accent, #2d6a8f)" stopOpacity="0.14" />
|
|
36
|
+
<stop offset="55%" stopColor="var(--cdl-tone-accent, #2d6a8f)" stopOpacity="0.04" />
|
|
37
|
+
<stop offset="100%" stopColor="var(--cdl-tone-accent, #2d6a8f)" stopOpacity="0" />
|
|
38
|
+
</radialGradient>
|
|
39
|
+
<filter id={shadowId} x="-50%" y="-50%" width="200%" height="200%">
|
|
40
|
+
<feGaussianBlur in="SourceAlpha" stdDeviation="4" />
|
|
41
|
+
<feOffset dx="0" dy="2" result="offsetblur" />
|
|
42
|
+
<feComponentTransfer>
|
|
43
|
+
<feFuncA type="linear" slope="0.4" />
|
|
44
|
+
</feComponentTransfer>
|
|
45
|
+
<feMerge>
|
|
46
|
+
<feMergeNode />
|
|
47
|
+
<feMergeNode in="SourceGraphic" />
|
|
48
|
+
</feMerge>
|
|
49
|
+
</filter>
|
|
50
|
+
</defs>
|
|
51
|
+
|
|
52
|
+
<rect
|
|
53
|
+
data-cdl-role="node-body"
|
|
54
|
+
x={0}
|
|
55
|
+
y={0}
|
|
56
|
+
width={node.w}
|
|
57
|
+
height={node.h}
|
|
58
|
+
rx={16}
|
|
59
|
+
fill="var(--cdl-node-fill, #ffffff)"
|
|
60
|
+
stroke={active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-divider, #d4d4d8)"}
|
|
61
|
+
strokeWidth={active ? 3 : 1.25}
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
<circle
|
|
65
|
+
cx={canvasCx}
|
|
66
|
+
cy={canvasCy}
|
|
67
|
+
r={maxRadius + 40}
|
|
68
|
+
fill={`url(#${gradientId})`}
|
|
69
|
+
pointerEvents="none"
|
|
70
|
+
/>
|
|
71
|
+
|
|
72
|
+
{layout.edges.map((e, i) => (
|
|
73
|
+
<line
|
|
74
|
+
key={`edge-${i}`}
|
|
75
|
+
data-cdl-role="mind-radial-edge"
|
|
76
|
+
x1={e.x1}
|
|
77
|
+
y1={e.y1}
|
|
78
|
+
x2={e.x2}
|
|
79
|
+
y2={e.y2}
|
|
80
|
+
stroke={toneColor(e.tone)}
|
|
81
|
+
strokeWidth={2.25}
|
|
82
|
+
strokeOpacity={0.7}
|
|
83
|
+
strokeLinecap="round"
|
|
84
|
+
/>
|
|
85
|
+
))}
|
|
86
|
+
|
|
87
|
+
{/* 中心の周りに敷く光の輪。 意図して淡く、 読ませる要素ではない。
|
|
88
|
+
役割を付けるのは、 consumer が色を当てたり検査から外したりできるようにするため
|
|
89
|
+
(役割の無い要素には CSS も検査も届かない)。 */}
|
|
90
|
+
<circle
|
|
91
|
+
data-cdl-role="mind-radial-halo"
|
|
92
|
+
cx={canvasCx}
|
|
93
|
+
cy={canvasCy}
|
|
94
|
+
r={56}
|
|
95
|
+
fill="var(--cdl-tone-accent, #2d6a8f)"
|
|
96
|
+
fillOpacity={0.18}
|
|
97
|
+
/>
|
|
98
|
+
<circle
|
|
99
|
+
cx={canvasCx}
|
|
100
|
+
cy={canvasCy}
|
|
101
|
+
r={42}
|
|
102
|
+
fill="var(--cdl-tone-accent, #2d6a8f)"
|
|
103
|
+
filter={`url(#${shadowId})`}
|
|
104
|
+
/>
|
|
105
|
+
<text
|
|
106
|
+
x={canvasCx}
|
|
107
|
+
y={canvasCy + 5}
|
|
108
|
+
fontSize={14}
|
|
109
|
+
fontWeight={700}
|
|
110
|
+
textAnchor="middle"
|
|
111
|
+
fill="var(--cdl-text-on-tone, #ffffff)"
|
|
112
|
+
>
|
|
113
|
+
{mindData.rootTitle}
|
|
114
|
+
</text>
|
|
115
|
+
|
|
116
|
+
{layout.nodes.filter((n) => !n.isRoot).map((n) => (
|
|
117
|
+
<g key={`n-${n.id}`}>
|
|
118
|
+
<rect
|
|
119
|
+
x={n.x - 62}
|
|
120
|
+
y={n.y - 22}
|
|
121
|
+
width={124}
|
|
122
|
+
height={44}
|
|
123
|
+
rx={12}
|
|
124
|
+
fill="var(--cdl-chip-fill, #f4f6fb)"
|
|
125
|
+
stroke={toneColor(n.tone)}
|
|
126
|
+
strokeWidth={2}
|
|
127
|
+
/>
|
|
128
|
+
<circle
|
|
129
|
+
cx={n.x - 62 + 12}
|
|
130
|
+
cy={n.y}
|
|
131
|
+
r={5}
|
|
132
|
+
fill={toneColor(n.tone)}
|
|
133
|
+
/>
|
|
134
|
+
<text
|
|
135
|
+
x={n.x + 6}
|
|
136
|
+
y={n.y + 5}
|
|
137
|
+
fontSize={13}
|
|
138
|
+
fontWeight={600}
|
|
139
|
+
textAnchor="middle"
|
|
140
|
+
fill="var(--cdl-chip-text, #1a1f2a)"
|
|
141
|
+
>
|
|
142
|
+
{n.title}
|
|
143
|
+
</text>
|
|
144
|
+
</g>
|
|
145
|
+
))}
|
|
146
|
+
</g>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
type LayoutNode = {
|
|
151
|
+
id: string;
|
|
152
|
+
title: string;
|
|
153
|
+
x: number;
|
|
154
|
+
y: number;
|
|
155
|
+
tone?: Tone;
|
|
156
|
+
isRoot: boolean;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
type LayoutEdge = {
|
|
160
|
+
x1: number;
|
|
161
|
+
y1: number;
|
|
162
|
+
x2: number;
|
|
163
|
+
y2: number;
|
|
164
|
+
tone?: Tone;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
function computeRadialLayout(
|
|
168
|
+
mindData: { rootId: string; rootTitle: string; branches: MindBranchNode[] },
|
|
169
|
+
cx: number,
|
|
170
|
+
cy: number,
|
|
171
|
+
maxRadius: number,
|
|
172
|
+
): { nodes: LayoutNode[]; edges: LayoutEdge[] } {
|
|
173
|
+
const nodes: LayoutNode[] = [];
|
|
174
|
+
const edges: LayoutEdge[] = [];
|
|
175
|
+
|
|
176
|
+
nodes.push({ id: mindData.rootId, title: mindData.rootTitle, x: cx, y: cy, isRoot: true });
|
|
177
|
+
|
|
178
|
+
const childrenOf = new Map<string, MindBranchNode[]>();
|
|
179
|
+
for (const br of mindData.branches) {
|
|
180
|
+
if (!childrenOf.has(br.parent)) childrenOf.set(br.parent, []);
|
|
181
|
+
childrenOf.get(br.parent)!.push(br);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const firstLevel = childrenOf.get(mindData.rootId) ?? [];
|
|
185
|
+
const angleStep = (Math.PI * 2) / 8;
|
|
186
|
+
firstLevel.slice(0, 8).forEach((br, idx) => {
|
|
187
|
+
const angle = -Math.PI / 2 + angleStep * idx;
|
|
188
|
+
const tone = br.tone ?? MIND_TONES[idx % MIND_TONES.length]!;
|
|
189
|
+
const r = maxRadius;
|
|
190
|
+
const x = cx + r * Math.cos(angle);
|
|
191
|
+
const y = cy + r * Math.sin(angle);
|
|
192
|
+
nodes.push({ id: br.id, title: br.title, x, y, tone, isRoot: false });
|
|
193
|
+
const rootR = 42;
|
|
194
|
+
const branchR = 62;
|
|
195
|
+
const x1 = cx + rootR * Math.cos(angle);
|
|
196
|
+
const y1 = cy + rootR * Math.sin(angle);
|
|
197
|
+
const x2 = x - branchR * Math.cos(angle);
|
|
198
|
+
const y2 = y - 22 * Math.sin(angle);
|
|
199
|
+
edges.push({ x1, y1, x2, y2, tone });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return { nodes, edges };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
function toneColor(tone: Tone | undefined): string {
|
|
207
|
+
if (!tone) return TONE.accent;
|
|
208
|
+
return TONE[tone];
|
|
209
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LaidNode, Tone } from "../types";
|
|
2
|
+
import { TONE } from "../render/tone";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* node の主色を決める。
|
|
6
|
+
*
|
|
7
|
+
* `tone` 指定があればその色、 無ければ呼び手が渡した kind の既定色を返す。 色を決める式を
|
|
8
|
+
* 1 箇所に集めるための helper で、 専用 component と `GenericNode` の双方がここを通る。
|
|
9
|
+
*/
|
|
10
|
+
export function nodeAccent(node: LaidNode, fallback: string): string {
|
|
11
|
+
return toneColor(node.tone) ?? fallback;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** tone に対応する色。 未指定 / 未知の値なら `undefined`。 */
|
|
15
|
+
export function toneColor(tone: Tone | undefined): string | undefined {
|
|
16
|
+
if (!tone) return undefined;
|
|
17
|
+
return TONE[tone];
|
|
18
|
+
}
|