@changfenhuang/dsh-genui 0.9.1
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 +367 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/README.zh-CN.md +261 -0
- package/SKILL.md +115 -0
- package/cordis.patch.yml +9 -0
- package/demo-prompts.md +118 -0
- package/lib/assets/echarts.js +64 -0
- package/lib/assets/mermaid.js +3591 -0
- package/lib/assets/three.js +3844 -0
- package/lib/client.js +5 -0
- package/lib/index.js +1895 -0
- package/lib/invariant.js +19 -0
- package/lib/types/client/EChartNode.d.ts +4 -0
- package/lib/types/client/ErrorBoundary.d.ts +30 -0
- package/lib/types/client/GenuiBlock.d.ts +7 -0
- package/lib/types/client/PlotBlock.d.ts +44 -0
- package/lib/types/client/action-context.d.ts +7 -0
- package/lib/types/client/asset-echarts.d.ts +15 -0
- package/lib/types/client/asset-loader.d.ts +31 -0
- package/lib/types/client/asset-mermaid.d.ts +1 -0
- package/lib/types/client/asset-three.d.ts +1 -0
- package/lib/types/client/blocks/advanced.d.ts +104 -0
- package/lib/types/client/blocks/basic.d.ts +27 -0
- package/lib/types/client/blocks/charts.d.ts +38 -0
- package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
- package/lib/types/client/blocks/diagram/index.d.ts +5 -0
- package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
- package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
- package/lib/types/client/blocks/forms.d.ts +83 -0
- package/lib/types/client/blocks/render-node.d.ts +10 -0
- package/lib/types/client/blocks/state.d.ts +58 -0
- package/lib/types/client/dom-fence.d.ts +14 -0
- package/lib/types/client/echarts-lazy.d.ts +17 -0
- package/lib/types/client/fence-render.d.ts +58 -0
- package/lib/types/client/gallery.d.ts +9 -0
- package/lib/types/client/guard.d.ts +126 -0
- package/lib/types/client/index.d.ts +36 -0
- package/lib/types/client/interaction-store.d.ts +43 -0
- package/lib/types/client/mermaid-core.d.ts +8 -0
- package/lib/types/client/mermaid-lazy.d.ts +9 -0
- package/lib/types/client/mermaid-safe.d.ts +52 -0
- package/lib/types/client/panel-command.d.ts +29 -0
- package/lib/types/client/panel-store.d.ts +94 -0
- package/lib/types/client/panel.d.ts +15 -0
- package/lib/types/client/parse-partial.d.ts +37 -0
- package/lib/types/client/safe-math.d.ts +43 -0
- package/lib/types/client/scene3d-core.d.ts +21 -0
- package/lib/types/client/scene3d-lazy.d.ts +8 -0
- package/lib/types/client/spec.d.ts +610 -0
- package/lib/types/client/toolview.d.ts +7 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/plugin/index.d.ts +25 -0
- package/lib/types/plugin/invariant.d.ts +15 -0
- package/lib/types/plugin/tool.d.ts +27 -0
- package/lib/types/shared/fence-repair.d.ts +67 -0
- package/package.json +121 -0
- package/src/client/EChartNode.tsx +242 -0
- package/src/client/ErrorBoundary.tsx +66 -0
- package/src/client/GenuiBlock.module.css +1263 -0
- package/src/client/GenuiBlock.tsx +166 -0
- package/src/client/PlotBlock.module.css +155 -0
- package/src/client/PlotBlock.tsx +431 -0
- package/src/client/action-context.ts +32 -0
- package/src/client/asset-echarts.ts +25 -0
- package/src/client/asset-loader.ts +79 -0
- package/src/client/asset-mermaid.ts +13 -0
- package/src/client/asset-three.ts +12 -0
- package/src/client/blocks/advanced.tsx +435 -0
- package/src/client/blocks/basic.tsx +116 -0
- package/src/client/blocks/charts.tsx +319 -0
- package/src/client/blocks/diagram/geometry.ts +151 -0
- package/src/client/blocks/diagram/index.tsx +284 -0
- package/src/client/blocks/diagram/layout.ts +199 -0
- package/src/client/blocks/diagram/theme.ts +108 -0
- package/src/client/blocks/forms.tsx +523 -0
- package/src/client/blocks/render-node.tsx +260 -0
- package/src/client/blocks/state.ts +61 -0
- package/src/client/dom-fence.tsx +641 -0
- package/src/client/echarts-lazy.ts +35 -0
- package/src/client/fence-render.tsx +230 -0
- package/src/client/gallery.ts +128 -0
- package/src/client/guard.ts +1450 -0
- package/src/client/index.tsx +195 -0
- package/src/client/interaction-store.ts +122 -0
- package/src/client/mermaid-core.ts +121 -0
- package/src/client/mermaid-lazy.ts +31 -0
- package/src/client/mermaid-safe.ts +122 -0
- package/src/client/panel-command.ts +119 -0
- package/src/client/panel-store.ts +471 -0
- package/src/client/panel.tsx +185 -0
- package/src/client/parse-partial.ts +162 -0
- package/src/client/safe-math.ts +270 -0
- package/src/client/scene3d-core.ts +192 -0
- package/src/client/scene3d-lazy.ts +26 -0
- package/src/client/spec.ts +767 -0
- package/src/client/toolview.tsx +66 -0
- package/src/css-modules.d.ts +5 -0
- package/src/index.ts +12 -0
- package/src/plugin/index.ts +173 -0
- package/src/plugin/invariant.ts +30 -0
- package/src/plugin/node-shims.d.ts +31 -0
- package/src/plugin/tool.ts +330 -0
- package/src/shared/fence-repair.ts +244 -0
- package/tsconfig.json +35 -0
- package/tsdown.config.ts +193 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editorial diagram renderer: `diagram` node → inline SVG. The browser-side
|
|
3
|
+
* port of the diagram-design editorial design system — semantic tokens,
|
|
4
|
+
* dotted-paper ground, orthogonal connectors, 4px grid, complexity budget,
|
|
5
|
+
* zone grouping, legend strip — as a white-listed dsh-genui component.
|
|
6
|
+
*
|
|
7
|
+
* Layout is resolved by `resolveLayout`, routing by `routeEdge`, and every
|
|
8
|
+
* value is sanitized by the guard before this component ever sees it.
|
|
9
|
+
* @module @changfenhuang/dsh-genui/client/blocks/diagram
|
|
10
|
+
*/
|
|
11
|
+
import { useMemo } from 'react'
|
|
12
|
+
import type { GenuiDiagram } from '../../spec.ts'
|
|
13
|
+
import { GENUI_LIMITS } from '../../guard.ts'
|
|
14
|
+
import { resolveLayout } from './layout.ts'
|
|
15
|
+
import { routeEdge, labelGeometry, type Box } from './geometry.ts'
|
|
16
|
+
import { resolvePalette, nodeTreatment, edgeStroke, inkAt } from './theme.ts'
|
|
17
|
+
|
|
18
|
+
const FONT_SANS = "'Geist', -apple-system, 'Segoe UI', sans-serif"
|
|
19
|
+
const FONT_MONO = "'Geist Mono', ui-monospace, 'SF Mono', monospace"
|
|
20
|
+
|
|
21
|
+
/** Editorial constants (diagram-design §5–7). */
|
|
22
|
+
const NODE_H = 64 // node box height (48→64 for the editorial ramp)
|
|
23
|
+
const LEGEND_H = 56
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Compute a deterministic parent map for tree-like kinds: the first edge
|
|
27
|
+
* into each node is its parent (a stable convention the model can rely on).
|
|
28
|
+
*/
|
|
29
|
+
function buildParentMap(edges: Array<{ from: string; to: string }>): (id: string) => string | undefined {
|
|
30
|
+
const parents = new Map<string, string>()
|
|
31
|
+
for (const e of edges) {
|
|
32
|
+
if (!parents.has(e.to)) parents.set(e.to, e.from)
|
|
33
|
+
}
|
|
34
|
+
return id => parents.get(id)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Render one node box + its text content (tag / name / sublabel / index). */
|
|
38
|
+
function renderNodeBox(
|
|
39
|
+
box: Box,
|
|
40
|
+
label: string,
|
|
41
|
+
sub: string | undefined,
|
|
42
|
+
type: string | undefined,
|
|
43
|
+
tag: string | undefined,
|
|
44
|
+
index: number,
|
|
45
|
+
palette: ReturnType<typeof resolvePalette>,
|
|
46
|
+
): React.ReactNode {
|
|
47
|
+
const treatment = nodeTreatment(type, palette)
|
|
48
|
+
const cx = box.x + box.w / 2
|
|
49
|
+
// Editorial 64px ramp: name centered, sublabel below, tag top-left, index bottom-right.
|
|
50
|
+
const nameY = box.y + (box.h - NODE_H) / 2 + 28
|
|
51
|
+
const subY = box.y + (box.h - NODE_H) / 2 + 44
|
|
52
|
+
const tagW = Math.min(44, Math.max(24, (tag ?? '').length * 7 + 8))
|
|
53
|
+
return (
|
|
54
|
+
<g>
|
|
55
|
+
{/* opaque paper mask: arrows must not bleed through transparent fills */}
|
|
56
|
+
<rect x={box.x} y={box.y} width={box.w} height={box.h} rx={6} fill={palette.paper} />
|
|
57
|
+
<rect
|
|
58
|
+
x={box.x}
|
|
59
|
+
y={box.y}
|
|
60
|
+
width={box.w}
|
|
61
|
+
height={box.h}
|
|
62
|
+
rx={6}
|
|
63
|
+
fill={treatment.fill}
|
|
64
|
+
stroke={treatment.stroke}
|
|
65
|
+
strokeWidth={1}
|
|
66
|
+
strokeDasharray={treatment.dashed === true ? '5,4' : undefined}
|
|
67
|
+
/>
|
|
68
|
+
{tag !== undefined && (
|
|
69
|
+
<>
|
|
70
|
+
<rect x={box.x + 8} y={box.y + 6} width={tagW} height={12} rx={2} fill="transparent" stroke={inkAt(treatment.stroke, 0.4)} strokeWidth={0.8} />
|
|
71
|
+
<text x={box.x + 8 + tagW / 2} y={box.y + 15} fill={inkAt(treatment.stroke, 0.8)} fontSize={7} fontFamily={FONT_MONO} textAnchor="middle" letterSpacing={0.8}>{tag}</text>
|
|
72
|
+
</>
|
|
73
|
+
)}
|
|
74
|
+
{/* Index number: bottom-right, half-transparent, editorial big numeral */}
|
|
75
|
+
<text x={box.x + box.w - 6} y={box.y + box.h - 6} fill={inkAt(treatment.stroke, 0.12)} fontSize={24} fontWeight={600} fontFamily={FONT_MONO} textAnchor="end">{String(index).padStart(2, '0')}</text>
|
|
76
|
+
<text x={cx} y={nameY} fill={palette.ink} fontSize={12} fontWeight={600} fontFamily={FONT_SANS} textAnchor="middle">{label}</text>
|
|
77
|
+
{sub !== undefined && (
|
|
78
|
+
<text x={cx} y={subY} fill={palette.soft} fontSize={9} fontFamily={FONT_MONO} textAnchor="middle">{sub}</text>
|
|
79
|
+
)}
|
|
80
|
+
</g>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Render one routed edge: path, optional label mask + text. */
|
|
85
|
+
function renderEdge(edge: { from: Box; to: Box; label?: string | undefined; kind?: string | undefined }, key: number, palette: ReturnType<typeof resolvePalette>, markerId: string): React.ReactNode {
|
|
86
|
+
const routed = routeEdge(edge.from, edge.to)
|
|
87
|
+
const stroke = edgeStroke(edge.kind, palette)
|
|
88
|
+
const dashed = edge.kind === 'dashed'
|
|
89
|
+
// Accent arrows are the focal signal: slightly heavier stroke (1.4).
|
|
90
|
+
const width = edge.kind === 'accent' ? 1.4 : 1.2
|
|
91
|
+
const lg = labelGeometry(routed.a, routed.b)
|
|
92
|
+
return (
|
|
93
|
+
<g key={key}>
|
|
94
|
+
<path
|
|
95
|
+
d={routed.d}
|
|
96
|
+
fill="none"
|
|
97
|
+
stroke={stroke}
|
|
98
|
+
strokeWidth={width}
|
|
99
|
+
strokeDasharray={dashed ? '5,4' : undefined}
|
|
100
|
+
markerEnd={`url(#${markerId})`}
|
|
101
|
+
/>
|
|
102
|
+
{edge.label !== undefined && (
|
|
103
|
+
<g>
|
|
104
|
+
<rect
|
|
105
|
+
x={lg.cx - (edge.label.length * 5 + 8) / 2}
|
|
106
|
+
y={lg.maskY}
|
|
107
|
+
width={edge.label.length * 5 + 8}
|
|
108
|
+
height={12}
|
|
109
|
+
rx={2}
|
|
110
|
+
fill={palette.paper}
|
|
111
|
+
/>
|
|
112
|
+
<text x={lg.cx} y={lg.maskY + 9} fill={palette.soft} fontSize={8} fontFamily={FONT_MONO} textAnchor="middle" letterSpacing={0.5}>{edge.label}</text>
|
|
113
|
+
</g>
|
|
114
|
+
)}
|
|
115
|
+
</g>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Arrow marker defs (default / accent / link), per-diagram unique ids. */
|
|
120
|
+
function renderMarkers(uid: string, palette: ReturnType<typeof resolvePalette>): React.ReactNode {
|
|
121
|
+
return (
|
|
122
|
+
<defs>
|
|
123
|
+
<marker id={`${uid}-arrow`} markerWidth={8} markerHeight={6} refX={7} refY={3} orient="auto">
|
|
124
|
+
<polygon points="0 0, 8 3, 0 6" fill={palette.muted} />
|
|
125
|
+
</marker>
|
|
126
|
+
<marker id={`${uid}-arrow-accent`} markerWidth={8} markerHeight={6} refX={7} refY={3} orient="auto">
|
|
127
|
+
<polygon points="0 0, 8 3, 0 6" fill={palette.accent} />
|
|
128
|
+
</marker>
|
|
129
|
+
<marker id={`${uid}-arrow-link`} markerWidth={8} markerHeight={6} refX={7} refY={3} orient="auto">
|
|
130
|
+
<polygon points="0 0, 8 3, 0 6" fill={palette.link} />
|
|
131
|
+
</marker>
|
|
132
|
+
</defs>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Dotted-paper ground: the editorial backdrop behind every diagram. */
|
|
137
|
+
function renderGround(uid: string, palette: ReturnType<typeof resolvePalette>): React.ReactNode {
|
|
138
|
+
return (
|
|
139
|
+
<>
|
|
140
|
+
<defs>
|
|
141
|
+
<pattern id={`${uid}-dots`} width={22} height={22} patternUnits="userSpaceOnUse">
|
|
142
|
+
<circle cx={1} cy={1} r={0.9} fill={inkAt(palette.ink, 0.10)} />
|
|
143
|
+
</pattern>
|
|
144
|
+
</defs>
|
|
145
|
+
<rect width="100%" height="100%" fill={palette.paper} />
|
|
146
|
+
<rect width="100%" height="100%" fill={`url(#${uid}-dots)`} opacity={0.55} />
|
|
147
|
+
</>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Zone container: hairline dashed rect + mono uppercase eyebrow label. */
|
|
152
|
+
function renderZone(z: { label: string; x: number; y: number; w: number; h: number }, palette: ReturnType<typeof resolvePalette>): React.ReactNode {
|
|
153
|
+
const labelW = z.label.length * 7 + 12
|
|
154
|
+
const cx = z.x + z.w / 2
|
|
155
|
+
return (
|
|
156
|
+
<g>
|
|
157
|
+
<rect x={z.x} y={z.y} width={z.w} height={z.h} rx={8} fill={inkAt(palette.ink, 0.02)} stroke={inkAt(palette.ink, 0.10)} strokeWidth={0.8} />
|
|
158
|
+
<rect x={cx - labelW / 2} y={z.y + 4} width={labelW} height={12} rx={2} fill={palette.paper} />
|
|
159
|
+
<text x={cx} y={z.y + 13} fill={inkAt(palette.ink, 0.40)} fontSize={7} fontFamily={FONT_MONO} textAnchor="middle" letterSpacing={1.2}>{z.label}</text>
|
|
160
|
+
</g>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Legend strip at the bottom: hairline separator + treatment swatches. */
|
|
165
|
+
function renderLegend(palette: ReturnType<typeof resolvePalette>): React.ReactNode {
|
|
166
|
+
const items: Array<{ label: string; type: string | undefined }> = [
|
|
167
|
+
{ label: 'Focal', type: 'focal' },
|
|
168
|
+
{ label: 'Backend', type: 'backend' },
|
|
169
|
+
{ label: 'Store', type: 'store' },
|
|
170
|
+
{ label: 'External', type: 'external' },
|
|
171
|
+
{ label: 'Optional', type: 'optional' },
|
|
172
|
+
{ label: 'Security', type: 'security' },
|
|
173
|
+
]
|
|
174
|
+
return (
|
|
175
|
+
<g>
|
|
176
|
+
<line x1={40} y1={0} x2={1000} y2={0} stroke={inkAt(palette.ink, 0.10)} strokeWidth={0.8} />
|
|
177
|
+
<text x={40} y={16} fill={palette.soft} fontSize={8} fontFamily={FONT_MONO} letterSpacing={1.6}>LEGEND</text>
|
|
178
|
+
{items.map((item, i) => {
|
|
179
|
+
const t = nodeTreatment(item.type, palette)
|
|
180
|
+
const x = 40 + i * 170
|
|
181
|
+
return (
|
|
182
|
+
<g key={item.label}>
|
|
183
|
+
<rect x={x} y={32} width={14} height={10} rx={2} fill={t.fill} stroke={t.stroke} strokeWidth={1} strokeDasharray={t.dashed === true ? '3,2' : undefined} />
|
|
184
|
+
<text x={x + 20} y={41} fill={palette.soft} fontSize={8.5} fontFamily={FONT_SANS}>{item.label}</text>
|
|
185
|
+
</g>
|
|
186
|
+
)
|
|
187
|
+
})}
|
|
188
|
+
</g>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function markerFor(kind: string | undefined, uid: string): string {
|
|
193
|
+
if (kind === 'accent') return `${uid}-arrow-accent`
|
|
194
|
+
if (kind === 'link') return `${uid}-arrow-link`
|
|
195
|
+
return `${uid}-arrow`
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Stable per-render uid (avoid id collisions across diagrams on one page). */
|
|
199
|
+
let uidCounter = 0
|
|
200
|
+
function nextUid(): string {
|
|
201
|
+
uidCounter += 1
|
|
202
|
+
return `genui-diagram-${uidCounter}`
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The `diagram` node renderer. */
|
|
206
|
+
export function DiagramNode({ node }: { node: GenuiDiagram }) {
|
|
207
|
+
const uid = useMemo(nextUid, [])
|
|
208
|
+
const palette = useMemo(() => resolvePalette(node.variant, node.theme), [node.variant, node.theme])
|
|
209
|
+
const layout = useMemo(() => resolveLayout(node, buildParentMap(node.edges ?? [])), [node])
|
|
210
|
+
|
|
211
|
+
const byId = useMemo(() => new Map(layout.nodes.map(l => [l.node.id, l.box])), [layout])
|
|
212
|
+
// figcaption gets its own id so aria-labelledby can point at the SVG <title>
|
|
213
|
+
// alone — sharing one id between the two renders duplicates ids in the DOM.
|
|
214
|
+
const titleId = `${uid}-title`
|
|
215
|
+
const captionId = `${uid}-caption`
|
|
216
|
+
const descId = `${uid}-desc`
|
|
217
|
+
|
|
218
|
+
// Complexity budget is enforced by the guard; belt-and-suspenders here.
|
|
219
|
+
const nodes = layout.nodes.slice(0, GENUI_LIMITS.maxDiagramNodes)
|
|
220
|
+
const edges = layout.edges.slice(0, GENUI_LIMITS.maxDiagramEdges)
|
|
221
|
+
const zones = (node.zones ?? []).slice(0, GENUI_LIMITS.maxDiagramZones)
|
|
222
|
+
|
|
223
|
+
// Focal budget: accent on 1–2 nodes max; extra focal nodes downgrade to backend.
|
|
224
|
+
let focalSeen = 0
|
|
225
|
+
const focalBudget = GENUI_LIMITS.maxDiagramFocal
|
|
226
|
+
|
|
227
|
+
// Canvas: content bounds + editorial chrome (bottom legend strip).
|
|
228
|
+
const contentH = layout.height
|
|
229
|
+
const canvasH = contentH + LEGEND_H + 24
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<figure className="genui-diagram" data-genui-diagram>
|
|
233
|
+
{node.title !== undefined && (
|
|
234
|
+
<figcaption id={captionId} style={{ fontFamily: "Instrument Serif, 'Times New Roman', serif", fontSize: 20, marginBottom: 8, color: palette.ink }}>
|
|
235
|
+
{node.title}
|
|
236
|
+
</figcaption>
|
|
237
|
+
)}
|
|
238
|
+
<svg
|
|
239
|
+
role="img"
|
|
240
|
+
aria-labelledby={titleId}
|
|
241
|
+
aria-describedby={descId}
|
|
242
|
+
viewBox={`0 0 ${layout.width} ${canvasH}`}
|
|
243
|
+
style={{ maxWidth: '100%', height: 'auto', background: palette.paper }}
|
|
244
|
+
>
|
|
245
|
+
<title id={titleId}>{node.title ?? node.kind}</title>
|
|
246
|
+
<desc id={descId}>{`${node.kind} diagram with ${nodes.length} nodes and ${edges.length} connectors`}</desc>
|
|
247
|
+
{renderMarkers(uid, palette)}
|
|
248
|
+
{renderGround(uid, palette)}
|
|
249
|
+
{/* zones drawn before arrows and nodes */}
|
|
250
|
+
<g>
|
|
251
|
+
{zones.map((z, i) => (
|
|
252
|
+
<g key={`zone-${i}`}>
|
|
253
|
+
{renderZone({ label: z.label, x: z.x ?? 0, y: z.y ?? 0, w: z.w ?? 100, h: z.h ?? 100 }, palette)}
|
|
254
|
+
</g>
|
|
255
|
+
))}
|
|
256
|
+
</g>
|
|
257
|
+
<g>
|
|
258
|
+
{edges.map((e, i) => {
|
|
259
|
+
const from = byId.get(e.fromId)
|
|
260
|
+
const to = byId.get(e.toId)
|
|
261
|
+
if (from === undefined || to === undefined) return null
|
|
262
|
+
return renderEdge({ from, to, label: e.edge.label, kind: e.edge.kind }, i, palette, markerFor(e.edge.kind, uid))
|
|
263
|
+
})}
|
|
264
|
+
</g>
|
|
265
|
+
{/* nodes after edges */}
|
|
266
|
+
<g>
|
|
267
|
+
{nodes.map((l, idx) => {
|
|
268
|
+
const effectiveType = l.node.type === 'focal' && focalSeen >= focalBudget ? undefined : l.node.type
|
|
269
|
+
if (l.node.type === 'focal') focalSeen += 1
|
|
270
|
+
return (
|
|
271
|
+
<g key={l.node.id}>
|
|
272
|
+
{renderNodeBox(l.box, l.node.label, l.node.sub, effectiveType, l.node.tag, idx + 1, palette)}
|
|
273
|
+
</g>
|
|
274
|
+
)
|
|
275
|
+
})}
|
|
276
|
+
</g>
|
|
277
|
+
{/* legend strip */}
|
|
278
|
+
<g transform={`translate(0, ${contentH + 8})`}>
|
|
279
|
+
{renderLegend(palette)}
|
|
280
|
+
</g>
|
|
281
|
+
</svg>
|
|
282
|
+
</figure>
|
|
283
|
+
)
|
|
284
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editorial diagram layout: resolves a `diagram` spec into positioned boxes
|
|
3
|
+
* and routed edges. Two layout modes mirror diagram-design's grammar:
|
|
4
|
+
*
|
|
5
|
+
* - Coordinate kinds (architecture, it-state, high-level, process,
|
|
6
|
+
* medallion, data-flow, dp-integration): the spec carries x/y/w/h per
|
|
7
|
+
* node; this module passes them through (4px-grid already enforced by the
|
|
8
|
+
* guard) and routes edges.
|
|
9
|
+
* - Rule kinds (flowchart, sequence, state, er, timeline, swimlane,
|
|
10
|
+
* quadrant, radar, loop, nested, tree, org-chart, layers, venn, pyramid,
|
|
11
|
+
* bar, line, gantt, scatter, dp-security-matrix): the spec carries data
|
|
12
|
+
* only; this module lays the nodes out on the editorial grid.
|
|
13
|
+
*
|
|
14
|
+
* The output is a plain layout object consumed by the SVG renderer — no
|
|
15
|
+
* React, no DOM, fully unit-testable.
|
|
16
|
+
* @module @changfenhuang/dsh-genui/client/blocks/diagram/layout
|
|
17
|
+
*/
|
|
18
|
+
import type { GenuiDiagram, GenuiDiagramEdge, GenuiDiagramKind, GenuiDiagramNode } from '../../spec.ts'
|
|
19
|
+
import { Box } from './geometry.ts'
|
|
20
|
+
|
|
21
|
+
export interface LayoutNode {
|
|
22
|
+
node: GenuiDiagramNode
|
|
23
|
+
box: Box
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LayoutEdge {
|
|
27
|
+
edge: GenuiDiagramEdge
|
|
28
|
+
/** Resolved box references for routing. */
|
|
29
|
+
fromId: string
|
|
30
|
+
toId: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DiagramLayout {
|
|
34
|
+
nodes: LayoutNode[]
|
|
35
|
+
edges: LayoutEdge[]
|
|
36
|
+
/** Canvas size (width/height). */
|
|
37
|
+
width: number
|
|
38
|
+
height: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Coordinate-mode kinds: nodes carry explicit positions. */
|
|
42
|
+
const COORDINATE_KINDS: readonly GenuiDiagramKind[] = [
|
|
43
|
+
'architecture', 'it-state', 'high-level', 'process', 'medallion', 'data-flow', 'dp-integration',
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const GAP = 32 // editorial gap between nodes
|
|
47
|
+
const NODE_W = 128
|
|
48
|
+
const NODE_H = 64
|
|
49
|
+
const MARGIN = 40
|
|
50
|
+
|
|
51
|
+
function isCoord(kind: GenuiDiagramKind): boolean {
|
|
52
|
+
return COORDINATE_KINDS.includes(kind)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Default box for a node without coordinates. */
|
|
56
|
+
function defaultBox(i: number, cols: number): Box {
|
|
57
|
+
const col = i % cols
|
|
58
|
+
const row = Math.floor(i / cols)
|
|
59
|
+
return {
|
|
60
|
+
x: MARGIN + col * (NODE_W + GAP),
|
|
61
|
+
y: MARGIN + row * (NODE_H + GAP),
|
|
62
|
+
w: NODE_W,
|
|
63
|
+
h: NODE_H,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Simple top-down flow layout: nodes in a column, one per row. */
|
|
68
|
+
function columnLayout(nodes: GenuiDiagramNode[]): LayoutNode[] {
|
|
69
|
+
return nodes.map((node, i) => ({
|
|
70
|
+
node,
|
|
71
|
+
box: { x: MARGIN, y: MARGIN + i * (NODE_H + GAP), w: NODE_W, h: NODE_H },
|
|
72
|
+
}))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Simple left-right flow layout: nodes in a row. */
|
|
76
|
+
function rowLayout(nodes: GenuiDiagramNode[]): LayoutNode[] {
|
|
77
|
+
return nodes.map((node, i) => ({
|
|
78
|
+
node,
|
|
79
|
+
box: { x: MARGIN + i * (NODE_W + GAP), y: MARGIN, w: NODE_W, h: NODE_H },
|
|
80
|
+
}))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Layered horizontal layout (layers, pyramid, gantt phases): stacked rows. */
|
|
84
|
+
function layerLayout(nodes: GenuiDiagramNode[], perRow = 3): LayoutNode[] {
|
|
85
|
+
return nodes.map((node, i) => {
|
|
86
|
+
const row = Math.floor(i / perRow)
|
|
87
|
+
const col = i % perRow
|
|
88
|
+
return {
|
|
89
|
+
node,
|
|
90
|
+
box: {
|
|
91
|
+
x: MARGIN + col * (NODE_W + GAP),
|
|
92
|
+
y: MARGIN + row * (NODE_H + GAP),
|
|
93
|
+
w: NODE_W,
|
|
94
|
+
h: NODE_H,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Tree layout: level = depth, children fan out horizontally under the parent. */
|
|
101
|
+
function treeLayout(nodes: GenuiDiagramNode[], parentOf: (id: string) => string | undefined): LayoutNode[] {
|
|
102
|
+
const byId = new Map(nodes.map(n => [n.id, n]))
|
|
103
|
+
const childrenOf = new Map<string, string[]>()
|
|
104
|
+
const roots: string[] = []
|
|
105
|
+
for (const n of nodes) {
|
|
106
|
+
const p = parentOf(n.id)
|
|
107
|
+
if (p === undefined || !byId.has(p)) {
|
|
108
|
+
roots.push(n.id)
|
|
109
|
+
} else {
|
|
110
|
+
const list = childrenOf.get(p) ?? []
|
|
111
|
+
list.push(n.id)
|
|
112
|
+
childrenOf.set(p, list)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const placed = new Map<string, Box>()
|
|
116
|
+
const depthOf = new Map<string, number>()
|
|
117
|
+
|
|
118
|
+
// measure: leaf width = 1; parent width = sum of children widths.
|
|
119
|
+
const measure = (id: string, depth: number): number => {
|
|
120
|
+
depthOf.set(id, depth)
|
|
121
|
+
const kids = childrenOf.get(id) ?? []
|
|
122
|
+
if (kids.length === 0) return 1
|
|
123
|
+
return kids.reduce((sum, k) => sum + measure(k, depth + 1), 0)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// assign: place children left→right, then center the parent above them.
|
|
127
|
+
const assign = (id: string, left: number): number => {
|
|
128
|
+
const depth = depthOf.get(id) ?? 0
|
|
129
|
+
const kids = childrenOf.get(id) ?? []
|
|
130
|
+
const w = NODE_W
|
|
131
|
+
let cursor = left
|
|
132
|
+
if (kids.length === 0) {
|
|
133
|
+
placed.set(id, { x: left, y: MARGIN + depth * (NODE_H + GAP * 1.5), w, h: NODE_H })
|
|
134
|
+
return left + w
|
|
135
|
+
}
|
|
136
|
+
for (const k of kids) cursor = assign(k, cursor)
|
|
137
|
+
const first = placed.get(kids[0]!)!
|
|
138
|
+
const last = placed.get(kids[kids.length - 1]!)!
|
|
139
|
+
const cx = (first.x + last.x + last.w) / 2 - w / 2
|
|
140
|
+
placed.set(id, { x: Math.max(left, cx), y: MARGIN + depth * (NODE_H + GAP * 1.5), w, h: NODE_H })
|
|
141
|
+
return cursor
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let left = MARGIN
|
|
145
|
+
for (const r of roots) {
|
|
146
|
+
left = assign(r, left) + GAP
|
|
147
|
+
}
|
|
148
|
+
return nodes.map(n => ({ node: n, box: placed.get(n.id) ?? { x: MARGIN, y: MARGIN, w: NODE_W, h: NODE_H } }))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resolve a diagram spec into a positioned layout. `parentOf` lets tree-like
|
|
153
|
+
* kinds pass their hierarchy; other rule kinds use a generic grid.
|
|
154
|
+
*/
|
|
155
|
+
export function resolveLayout(
|
|
156
|
+
diagram: GenuiDiagram,
|
|
157
|
+
parentOf?: (id: string) => string | undefined,
|
|
158
|
+
): DiagramLayout {
|
|
159
|
+
const nodes = diagram.nodes
|
|
160
|
+
const edges = diagram.edges ?? []
|
|
161
|
+
|
|
162
|
+
let placed: LayoutNode[]
|
|
163
|
+
if (isCoord(diagram.kind)) {
|
|
164
|
+
placed = nodes.map((node, i) => ({
|
|
165
|
+
node,
|
|
166
|
+
box: {
|
|
167
|
+
x: node.x ?? MARGIN + (i % 3) * (NODE_W + GAP),
|
|
168
|
+
y: node.y ?? MARGIN + Math.floor(i / 3) * (NODE_H + GAP),
|
|
169
|
+
w: node.w ?? NODE_W,
|
|
170
|
+
h: node.h ?? NODE_H,
|
|
171
|
+
},
|
|
172
|
+
}))
|
|
173
|
+
} else if (diagram.kind === 'tree' || diagram.kind === 'org-chart' || diagram.kind === 'nested') {
|
|
174
|
+
placed = treeLayout(nodes, parentOf ?? (() => undefined))
|
|
175
|
+
} else if (diagram.kind === 'layers' || diagram.kind === 'pyramid' || diagram.kind === 'gantt') {
|
|
176
|
+
placed = layerLayout(nodes)
|
|
177
|
+
} else if (diagram.kind === 'flowchart' || diagram.kind === 'process' || diagram.kind === 'sequence') {
|
|
178
|
+
// Directed flows read top-down.
|
|
179
|
+
placed = columnLayout(nodes)
|
|
180
|
+
} else if (diagram.kind === 'timeline' || diagram.kind === 'line' || diagram.kind === 'scatter' || diagram.kind === 'bar') {
|
|
181
|
+
placed = rowLayout(nodes)
|
|
182
|
+
} else {
|
|
183
|
+
placed = nodes.map((node, i) => ({ node, box: defaultBox(i, 3) }))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Canvas bounds.
|
|
187
|
+
let width = 0
|
|
188
|
+
let height = 0
|
|
189
|
+
for (const l of placed) {
|
|
190
|
+
width = Math.max(width, l.box.x + l.box.w)
|
|
191
|
+
height = Math.max(height, l.box.y + l.box.h)
|
|
192
|
+
}
|
|
193
|
+
width = Math.max(320, Math.ceil((width + MARGIN) / 4) * 4)
|
|
194
|
+
height = Math.max(200, Math.ceil((height + MARGIN) / 4) * 4)
|
|
195
|
+
|
|
196
|
+
const edgesOut: LayoutEdge[] = edges.map(e => ({ edge: e, fromId: e.from, toId: e.to }))
|
|
197
|
+
|
|
198
|
+
return { nodes: placed, edges: edgesOut, width, height }
|
|
199
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editorial diagram theme: the diagram-design semantic-token system ported
|
|
3
|
+
* into the dsh-genui renderer. Every diagram draws from these roles — never
|
|
4
|
+
* from inline hex in the layout code — and a spec-level `theme` override
|
|
5
|
+
* merges over the defaults, mirroring diagram-design's style-guide.md.
|
|
6
|
+
*
|
|
7
|
+
* Two skins ship: light and dark (the dark palette is the jet-black
|
|
8
|
+
* inversion, same roles). The `editorial` variant is the full skin and
|
|
9
|
+
* defaults to light unless the host theme is dark.
|
|
10
|
+
* @module @changfenhuang/dsh-genui/client/blocks/diagram/theme
|
|
11
|
+
*/
|
|
12
|
+
import type { GenuiDiagramTheme, GenuiDiagramVariant } from '../../spec.ts'
|
|
13
|
+
|
|
14
|
+
export interface DiagramPalette {
|
|
15
|
+
paper: string
|
|
16
|
+
paper2: string
|
|
17
|
+
ink: string
|
|
18
|
+
muted: string
|
|
19
|
+
soft: string
|
|
20
|
+
rule: string
|
|
21
|
+
accent: string
|
|
22
|
+
accentTint: string
|
|
23
|
+
link: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** diagram-design default skin (style-guide.md § Tokens). */
|
|
27
|
+
const LIGHT: DiagramPalette = {
|
|
28
|
+
paper: '#f5f5f5',
|
|
29
|
+
paper2: '#ececec',
|
|
30
|
+
ink: '#2d3142',
|
|
31
|
+
muted: '#4f5d75',
|
|
32
|
+
soft: '#7a8399',
|
|
33
|
+
rule: 'rgba(45,49,66,0.12)',
|
|
34
|
+
accent: '#eb6c36',
|
|
35
|
+
accentTint: 'rgba(235,108,54,0.08)',
|
|
36
|
+
link: '#2e5aa8',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Dark inversion: jet-black paper, white-smoke ink, silver muted. */
|
|
40
|
+
const DARK: DiagramPalette = {
|
|
41
|
+
paper: '#2d3142',
|
|
42
|
+
paper2: '#393e53',
|
|
43
|
+
ink: '#f5f5f5',
|
|
44
|
+
muted: '#bfc0c0',
|
|
45
|
+
soft: '#8e98ac',
|
|
46
|
+
rule: 'rgba(245,245,245,0.12)',
|
|
47
|
+
accent: '#f08a59',
|
|
48
|
+
accentTint: 'rgba(240,138,89,0.10)',
|
|
49
|
+
link: '#6a95d8',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Resolve the active palette from variant + optional theme overrides. */
|
|
53
|
+
export function resolvePalette(variant: GenuiDiagramVariant | undefined, theme: GenuiDiagramTheme | undefined): DiagramPalette {
|
|
54
|
+
const base = variant === 'dark' ? DARK : LIGHT
|
|
55
|
+
if (theme === undefined) return base
|
|
56
|
+
return {
|
|
57
|
+
paper: theme.paper ?? base.paper,
|
|
58
|
+
paper2: theme['paper-2'] ?? base.paper2,
|
|
59
|
+
ink: theme.ink ?? base.ink,
|
|
60
|
+
muted: theme.muted ?? base.muted,
|
|
61
|
+
soft: theme.soft ?? base.soft,
|
|
62
|
+
rule: theme.rule ?? base.rule,
|
|
63
|
+
accent: theme.accent ?? base.accent,
|
|
64
|
+
accentTint: theme['accent-tint'] ?? base.accentTint,
|
|
65
|
+
link: theme.link ?? base.link,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Node treatment → { fill, stroke, dashed } per diagram-design §5. */
|
|
70
|
+
export function nodeTreatment(type: string | undefined, p: DiagramPalette): { fill: string; stroke: string; dashed?: boolean } {
|
|
71
|
+
switch (type) {
|
|
72
|
+
case 'focal': return { fill: p.accentTint, stroke: p.accent }
|
|
73
|
+
case 'store': return { fill: inkAt(p.ink, 0.05), stroke: p.muted }
|
|
74
|
+
case 'external': return { fill: inkAt(p.ink, 0.03), stroke: inkAt(p.ink, 0.30) }
|
|
75
|
+
case 'input': return { fill: inkAt(p.muted, 0.10), stroke: p.soft }
|
|
76
|
+
case 'optional': return { fill: inkAt(p.ink, 0.02), stroke: inkAt(p.ink, 0.20), dashed: true }
|
|
77
|
+
case 'security': return { fill: inkAt(p.accent, 0.05), stroke: inkAt(p.accent, 0.50), dashed: true }
|
|
78
|
+
default: return { fill: '#ffffff', stroke: p.ink }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Edge stroke color per semantic kind. */
|
|
83
|
+
export function edgeStroke(kind: string | undefined, p: DiagramPalette): string {
|
|
84
|
+
switch (kind) {
|
|
85
|
+
case 'accent': return p.accent
|
|
86
|
+
case 'link': return p.link
|
|
87
|
+
default: return p.muted
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Helper: a color at a given opacity (accepts #hex and rgba() strings). */
|
|
92
|
+
export function inkAt(color: string, opacity: number): string {
|
|
93
|
+
if (color.startsWith('#')) {
|
|
94
|
+
const hex = color.slice(1)
|
|
95
|
+
const full = hex.length === 3 ? hex.split('').map(c => c + c).join('') : hex
|
|
96
|
+
const r = parseInt(full.slice(0, 2), 16)
|
|
97
|
+
const g = parseInt(full.slice(2, 4), 16)
|
|
98
|
+
const b = parseInt(full.slice(4, 6), 16)
|
|
99
|
+
return `rgba(${r},${g},${b},${opacity})`
|
|
100
|
+
}
|
|
101
|
+
// rgba(...) input: replace the alpha component.
|
|
102
|
+
return color.replace(/rgba?\(([^)]*)\)/, (_, inner: string) => {
|
|
103
|
+
const parts = inner.split(',').map(s => s.trim())
|
|
104
|
+
if (parts.length === 3) return `rgba(${parts.join(',')},${opacity})`
|
|
105
|
+
if (parts.length === 4) return `rgba(${parts[0]},${parts[1]},${parts[2]},${opacity})`
|
|
106
|
+
return color
|
|
107
|
+
})
|
|
108
|
+
}
|