@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,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlotBlock: renders one or more math functions as an SVG line chart. The
|
|
3
|
+
* model supplies expressions (e.g. "sin(x)") which are evaluated by the
|
|
4
|
+
* SafeMath white-listed evaluator — never eval — and sampled into polylines.
|
|
5
|
+
* Pure SVG: linear x/y scales computed in place, no chart library.
|
|
6
|
+
*
|
|
7
|
+
* v2 interactivity: series may declare `params` (e.g. {a: 2} in "a*sin(x)")
|
|
8
|
+
* which render as live sliders under the chart — dragging re-samples the
|
|
9
|
+
* curve in place. The plot itself supports drag-to-pan and wheel-to-zoom.
|
|
10
|
+
*/
|
|
11
|
+
import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
12
|
+
import { sampleExpr } from './safe-math.ts'
|
|
13
|
+
import css from './PlotBlock.module.css'
|
|
14
|
+
|
|
15
|
+
export interface PlotSeriesParam {
|
|
16
|
+
name: string
|
|
17
|
+
value: number
|
|
18
|
+
min?: number | undefined
|
|
19
|
+
max?: number | undefined
|
|
20
|
+
step?: number | undefined
|
|
21
|
+
/** v1.5: animate this parameter from its initial value to this target. */
|
|
22
|
+
animateTo?: number | undefined
|
|
23
|
+
/** Animation duration in ms (default 4000). */
|
|
24
|
+
durationMs?: number | undefined
|
|
25
|
+
/** Loop the animation (default false). */
|
|
26
|
+
loop?: boolean | undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PlotSeries {
|
|
30
|
+
/** Math expression in x. */
|
|
31
|
+
expr: string
|
|
32
|
+
/** Optional label shown in a legend row. */
|
|
33
|
+
label?: string | undefined
|
|
34
|
+
/** Stroke color; defaults to the accent token (multi-series auto-assign). */
|
|
35
|
+
color?: string | undefined
|
|
36
|
+
/** v2.9 draw shape: line (default), area (fill to baseline), scatter. */
|
|
37
|
+
kind?: 'line' | 'area' | 'scatter' | undefined
|
|
38
|
+
/** v2: adjustable parameters, one slider each, live re-render. */
|
|
39
|
+
params?: PlotSeriesParam[] | undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Categorical palette for multi-series plots: host static tokens only
|
|
43
|
+
* (design system v2 — same families as the chart/avatar palettes in
|
|
44
|
+
* GenuiBlock, no off-theme hexes). CSS custom properties resolve inside
|
|
45
|
+
* inline `style` on SVG strokes and legend swatches. */
|
|
46
|
+
export const PLOT_COLORS = [
|
|
47
|
+
'var(--dsw-static-deepseek-400)',
|
|
48
|
+
'var(--dsw-static-green-400)',
|
|
49
|
+
'var(--dsw-static-amber-400)',
|
|
50
|
+
'var(--dsw-static-red-400)',
|
|
51
|
+
'var(--dsw-static-blue-450)',
|
|
52
|
+
'var(--dsw-static-deepseek-450)',
|
|
53
|
+
'var(--dsw-static-neutral-bluish-400)',
|
|
54
|
+
'var(--dsw-static-deepseek-300)',
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
/** Series color: explicit wins; multi-series auto-assign from the palette. */
|
|
58
|
+
const seriesColor = (i: number, n: number, c?: string): string | undefined =>
|
|
59
|
+
c ?? (n > 1 ? PLOT_COLORS[i % PLOT_COLORS.length] : undefined)
|
|
60
|
+
|
|
61
|
+
export interface PlotBlockProps {
|
|
62
|
+
/** Functions to draw, in draw order. */
|
|
63
|
+
series: PlotSeries[]
|
|
64
|
+
/** Horizontal range, inclusive. */
|
|
65
|
+
xMin?: number | undefined
|
|
66
|
+
xMax?: number | undefined
|
|
67
|
+
/** Vertical bounds; omitted = auto-fit to the sampled data. */
|
|
68
|
+
yMin?: number | undefined
|
|
69
|
+
yMax?: number | undefined
|
|
70
|
+
/** Chart title. */
|
|
71
|
+
title?: string | undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const WIDTH = 480
|
|
75
|
+
const HEIGHT = 220
|
|
76
|
+
const PAD_L = 34
|
|
77
|
+
const PAD_R = 10
|
|
78
|
+
const PAD_T = 14
|
|
79
|
+
const PAD_B = 24
|
|
80
|
+
const SAMPLES = 240
|
|
81
|
+
|
|
82
|
+
/** Auto-fit y-range from finite samples with a small margin. */
|
|
83
|
+
function fitY(points: ReadonlyArray<readonly [number, number]>): [number, number] {
|
|
84
|
+
let lo = Infinity
|
|
85
|
+
let hi = -Infinity
|
|
86
|
+
for (const [, y] of points) {
|
|
87
|
+
if (y < lo) lo = y
|
|
88
|
+
if (y > hi) hi = y
|
|
89
|
+
}
|
|
90
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [-1, 1]
|
|
91
|
+
if (lo === hi) { lo -= 1; hi += 1 }
|
|
92
|
+
const pad = (hi - lo) * 0.08
|
|
93
|
+
return [lo - pad, hi + pad]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function niceTicks(min: number, max: number, count = 5): number[] {
|
|
97
|
+
const span = max - min
|
|
98
|
+
if (!Number.isFinite(span) || span <= 0) return []
|
|
99
|
+
const step = Math.pow(10, Math.floor(Math.log10(span / count)))
|
|
100
|
+
const err = span / count / step
|
|
101
|
+
const mult = err >= 7.5 ? 10 : err >= 3.5 ? 5 : err >= 1.5 ? 2 : 1
|
|
102
|
+
const size = step * mult
|
|
103
|
+
const ticks: number[] = []
|
|
104
|
+
for (let v = Math.ceil(min / size) * size; v <= max + size * 1e-9; v += size) {
|
|
105
|
+
ticks.push(Math.round(v * 1e9) / 1e9)
|
|
106
|
+
}
|
|
107
|
+
return ticks
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatTick(v: number): string {
|
|
111
|
+
if (Math.abs(v) >= 1e5 || (Math.abs(v) < 1e-3 && v !== 0)) return v.toExponential(1)
|
|
112
|
+
return String(Math.round(v * 100) / 100)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Polyline with manual points sync: React 18 does not reliably update the
|
|
116
|
+
* SVG `points` attribute when only the value changes (the element is diffed
|
|
117
|
+
* but the attribute write is skipped), so this wrapper writes it through a
|
|
118
|
+
* ref on every render. */
|
|
119
|
+
function Polyline({ points, className, color }: { points: string; className: string | undefined; color?: string | undefined }) {
|
|
120
|
+
// React 18 does not reliably update the SVG `points` attribute when the
|
|
121
|
+
// value changes on an existing element (the DOM write is skipped). Keying
|
|
122
|
+
// the element on a prefix of the points string forces a fresh element per
|
|
123
|
+
// distinct curve, which sidesteps the update entirely. The prefix must
|
|
124
|
+
// change whenever the curve changes: include the first and last point.
|
|
125
|
+
const key = points.length > 40 ? `${points.slice(0, 16)}|${points.slice(-16)}` : points
|
|
126
|
+
return (
|
|
127
|
+
<polyline
|
|
128
|
+
key={key}
|
|
129
|
+
points={points}
|
|
130
|
+
className={className}
|
|
131
|
+
style={color !== undefined ? { stroke: color } : undefined}
|
|
132
|
+
/>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Render one plot: grid, axes, one polyline per series, legend, sliders. */
|
|
137
|
+
export const PlotBlock = memo(function PlotBlock({
|
|
138
|
+
series, xMin: propXMin = -5, xMax: propXMax = 5, yMin, yMax, title,
|
|
139
|
+
}: PlotBlockProps) {
|
|
140
|
+
// Pan/zoom view state: x range plus a LOCKED y range. The y range is fit
|
|
141
|
+
// ONCE from the initial parameters and then fixed, so dragging a parameter
|
|
142
|
+
// slider changes the curve's SHAPE (amplitude/slope) instead of rescaling
|
|
143
|
+
// the axis every time — the user sees the function move, not the numbers.
|
|
144
|
+
const [view, setView] = useState(() => {
|
|
145
|
+
// Fit y from the default parameter values (the same fallback the render
|
|
146
|
+
// path uses), so the initial axis already frames the default curve.
|
|
147
|
+
const defaults: Record<string, number> = {}
|
|
148
|
+
for (const [si, s] of series.entries()) {
|
|
149
|
+
for (const p of s.params ?? []) {
|
|
150
|
+
if (p !== null && p !== undefined) defaults[`${si}:${p.name}`] = p.value
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const probe = series.map((s, si) => {
|
|
154
|
+
const p: Record<string, number> = {}
|
|
155
|
+
for (const param of s.params ?? []) {
|
|
156
|
+
if (param === null || param === undefined) continue
|
|
157
|
+
p[param.name] = defaults[`${si}:${param.name}`] ?? param.value
|
|
158
|
+
}
|
|
159
|
+
return sampleExpr(s.expr, propXMin, propXMax, SAMPLES, p)
|
|
160
|
+
})
|
|
161
|
+
const [autoLo, autoHi] = fitY(probe.flatMap(p => p))
|
|
162
|
+
return {
|
|
163
|
+
xMin: propXMin,
|
|
164
|
+
xMax: propXMax,
|
|
165
|
+
yMin: yMin ?? autoLo,
|
|
166
|
+
yMax: yMax ?? autoHi,
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
const dragRef = useRef<{ startX: number; startY: number; xMin: number; xMax: number } | null>(null)
|
|
170
|
+
|
|
171
|
+
// Parameter values keyed by series index + param name; slider drag updates.
|
|
172
|
+
// Deliberately starts EMPTY: the initializer must never read series props
|
|
173
|
+
// (a parent re-mount can rebuild them mid-interaction), so every value
|
|
174
|
+
// falls back to the param's declared default at render time. This makes
|
|
175
|
+
// remounts during slider drags crash-proof.
|
|
176
|
+
const [params, setParams] = useState<Record<string, number>>(() => ({}))
|
|
177
|
+
|
|
178
|
+
// Animation: pick the first parameter that declares animateTo. A play/pause
|
|
179
|
+
// button drives it from its current value to animateTo over durationMs
|
|
180
|
+
// using requestAnimationFrame; the curve moves in place (y axis is locked).
|
|
181
|
+
const animParam = (() => {
|
|
182
|
+
for (const [si, s] of series.entries()) {
|
|
183
|
+
for (const p of s.params ?? []) {
|
|
184
|
+
if (p !== null && p !== undefined && p.animateTo !== undefined) {
|
|
185
|
+
return { si, param: p }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null
|
|
190
|
+
})()
|
|
191
|
+
const [playing, setPlaying] = useState(false)
|
|
192
|
+
const [animProgress, setAnimProgress] = useState(0)
|
|
193
|
+
const animRef = useRef<number | null>(null)
|
|
194
|
+
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (!playing || animParam === null) return
|
|
197
|
+
const from = params[`${animParam.si}:${animParam.param.name}`] ?? animParam.param.value
|
|
198
|
+
const to = animParam.param.animateTo!
|
|
199
|
+
const duration = animParam.param.durationMs ?? 4000
|
|
200
|
+
const start = performance.now()
|
|
201
|
+
const tick = (now: number): void => {
|
|
202
|
+
const t = Math.min(1, (now - start) / duration)
|
|
203
|
+
const eased = 1 - Math.pow(1 - t, 3) // easeOutCubic
|
|
204
|
+
const value = from + (to - from) * eased
|
|
205
|
+
setParams(prev => ({ ...prev, [`${animParam.si}:${animParam.param.name}`]: value }))
|
|
206
|
+
setAnimProgress(t)
|
|
207
|
+
if (t < 1) {
|
|
208
|
+
animRef.current = requestAnimationFrame(tick)
|
|
209
|
+
} else if (animParam.param.loop === true) {
|
|
210
|
+
setParams(prev => ({ ...prev, [`${animParam.si}:${animParam.param.name}`]: animParam.param.value }))
|
|
211
|
+
setAnimProgress(0)
|
|
212
|
+
animRef.current = requestAnimationFrame(() => setPlaying(p => p))
|
|
213
|
+
} else {
|
|
214
|
+
setPlaying(false)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
animRef.current = requestAnimationFrame(tick)
|
|
218
|
+
return () => { if (animRef.current !== null) cancelAnimationFrame(animRef.current) }
|
|
219
|
+
}, [playing, animParam?.si, animParam?.param.name, animParam?.param.animateTo])
|
|
220
|
+
|
|
221
|
+
const xMin = view.xMin
|
|
222
|
+
const xMax = view.xMax
|
|
223
|
+
const lo = view.yMin
|
|
224
|
+
const hi = view.yMax
|
|
225
|
+
const plotW = WIDTH - PAD_L - PAD_R
|
|
226
|
+
const plotH = HEIGHT - PAD_T - PAD_B
|
|
227
|
+
const fromX = (px: number): number => xMin + ((px - PAD_L) / plotW) * (xMax - xMin)
|
|
228
|
+
const toX = (x: number): number => PAD_L + ((x - xMin) / (xMax - xMin)) * plotW
|
|
229
|
+
const toY = (y: number): number => PAD_T + (1 - (y - lo) / (hi - lo)) * plotH
|
|
230
|
+
|
|
231
|
+
// Sample with current parameter values, then convert to screen coordinates
|
|
232
|
+
// in one pass against the LOCKED y range — the `points` string changes with
|
|
233
|
+
// the parameters, so the curve's shape visibly responds to the slider.
|
|
234
|
+
const sampled = series.map((s, si) => {
|
|
235
|
+
const p: Record<string, number> = {}
|
|
236
|
+
for (const param of s.params ?? []) {
|
|
237
|
+
if (param === null || param === undefined) continue
|
|
238
|
+
p[param.name] = params[`${si}:${param.name}`] ?? param.value
|
|
239
|
+
}
|
|
240
|
+
const pts = sampleExpr(s.expr, xMin, xMax, SAMPLES, p)
|
|
241
|
+
return {
|
|
242
|
+
series: s,
|
|
243
|
+
points: pts.map(([x, y]) => `${toX(x).toFixed(2)},${toY(y).toFixed(2)}`).join(' '),
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const xTicks = niceTicks(xMin, xMax)
|
|
248
|
+
const yTicks = niceTicks(lo, hi)
|
|
249
|
+
const hasData = sampled.some(p => p.points.length > 1)
|
|
250
|
+
const hasValidRange = Number.isFinite(xMin) && Number.isFinite(xMax) && xMax > xMin && Number.isFinite(hi) && Number.isFinite(lo) && hi > lo
|
|
251
|
+
|
|
252
|
+
// v2.9 draw shapes: line (polyline), area (fill to the locked baseline),
|
|
253
|
+
// scatter (dots only). Colors stay on the shared categorical palette.
|
|
254
|
+
const renderSeries = (s: PlotSeries, i: number, points: string): ReactNode => {
|
|
255
|
+
const color = seriesColor(i, sampled.length, s.color) ?? 'var(--dsw-alias-state-business-primary, #4f8ef7)'
|
|
256
|
+
const kind = s.kind ?? 'line'
|
|
257
|
+
if (kind === 'scatter') {
|
|
258
|
+
const coords = points === '' ? [] : points.split(' ').map(pt => {
|
|
259
|
+
const [x, y] = pt.split(',')
|
|
260
|
+
return [Number(x), Number(y)] as const
|
|
261
|
+
})
|
|
262
|
+
return (
|
|
263
|
+
<g key={i}>
|
|
264
|
+
{coords.map(([cx, cy], k) => <circle key={k} cx={cx} cy={cy} r={2.6} className={css.scatterDot} style={{ fill: color }} />)}
|
|
265
|
+
</g>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
if (kind === 'area') {
|
|
269
|
+
if (points === '') return <Polyline key={i} points="" className={css.line} color={color} />
|
|
270
|
+
const firstX = points.slice(0, points.indexOf(' ')).split(',')[0]!
|
|
271
|
+
const lastX = points.slice(points.lastIndexOf(' ') + 1).split(',')[0]!
|
|
272
|
+
const baseY = toY(lo)
|
|
273
|
+
const poly = `${firstX},${baseY.toFixed(2)} ${points} ${lastX},${baseY.toFixed(2)}`
|
|
274
|
+
return <polygon key={i} points={poly} className={css.area} style={{ fill: color }} />
|
|
275
|
+
}
|
|
276
|
+
return <Polyline key={i} points={points} className={css.line} color={color} />
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Drag-to-pan and wheel-to-zoom on the SVG surface. Pan/zoom move the x
|
|
280
|
+
// window only; the y range stays locked so parameter changes stay visible.
|
|
281
|
+
const onPointerDown = (e: React.PointerEvent): void => {
|
|
282
|
+
dragRef.current = { startX: e.clientX, startY: e.clientY, xMin, xMax }
|
|
283
|
+
;(e.target as Element).setPointerCapture?.(e.pointerId)
|
|
284
|
+
}
|
|
285
|
+
const onPointerMove = (e: React.PointerEvent): void => {
|
|
286
|
+
const d = dragRef.current
|
|
287
|
+
if (d === null) return
|
|
288
|
+
const span = d.xMax - d.xMin
|
|
289
|
+
const dx = ((d.startX - e.clientX) / plotW) * span
|
|
290
|
+
setView(prev => ({ xMin: d.xMin + dx, xMax: d.xMax + dx, yMin: prev.yMin, yMax: prev.yMax }))
|
|
291
|
+
}
|
|
292
|
+
const onPointerUp = (): void => { dragRef.current = null }
|
|
293
|
+
const onWheel = (e: React.WheelEvent): void => {
|
|
294
|
+
const span = xMax - xMin
|
|
295
|
+
const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15
|
|
296
|
+
const next = span * factor
|
|
297
|
+
const rect = (e.currentTarget as Element).getBoundingClientRect()
|
|
298
|
+
const cx = fromX(((e.clientX - rect.left) / rect.width) * WIDTH)
|
|
299
|
+
const left = cx - ((cx - xMin) / span) * next
|
|
300
|
+
setView(prev => ({ xMin: left, xMax: left + next, yMin: prev.yMin, yMax: prev.yMax }))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const hasParams = series.some(s => (s.params?.length ?? 0) > 0)
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
<div className={css.block} data-genui-plot>
|
|
307
|
+
{title !== undefined && <div className={css.title}>{title}</div>}
|
|
308
|
+
{hasData && hasValidRange ? (
|
|
309
|
+
<svg
|
|
310
|
+
width="100%"
|
|
311
|
+
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
|
|
312
|
+
role="img"
|
|
313
|
+
aria-label={title ?? 'function plot'}
|
|
314
|
+
className={css.surface}
|
|
315
|
+
onPointerDown={onPointerDown}
|
|
316
|
+
onPointerMove={onPointerMove}
|
|
317
|
+
onPointerUp={onPointerUp}
|
|
318
|
+
onPointerLeave={onPointerUp}
|
|
319
|
+
onWheel={onWheel}
|
|
320
|
+
>
|
|
321
|
+
{/* y grid */}
|
|
322
|
+
{yTicks.map(t => (
|
|
323
|
+
<g key={`y${t}`}>
|
|
324
|
+
<line x1={PAD_L} x2={WIDTH - PAD_R} y1={toY(t)} y2={toY(t)} className={css.gridLine} />
|
|
325
|
+
<text x={PAD_L - 6} y={toY(t) + 4} className={css.tick} textAnchor="end">{formatTick(t)}</text>
|
|
326
|
+
</g>
|
|
327
|
+
))}
|
|
328
|
+
{/* x grid */}
|
|
329
|
+
{xTicks.map(t => (
|
|
330
|
+
<g key={`x${t}`}>
|
|
331
|
+
<line x1={toX(t)} x2={toX(t)} y1={PAD_T} y2={HEIGHT - PAD_B} className={css.gridLine} />
|
|
332
|
+
<text x={toX(t)} y={HEIGHT - PAD_B + 14} className={css.tick} textAnchor="middle">{formatTick(t)}</text>
|
|
333
|
+
</g>
|
|
334
|
+
))}
|
|
335
|
+
{/* axes */}
|
|
336
|
+
<line x1={PAD_L} x2={WIDTH - PAD_R} y1={HEIGHT - PAD_B} y2={HEIGHT - PAD_B} className={css.axis} />
|
|
337
|
+
<line x1={PAD_L} x2={PAD_L} y1={PAD_T} y2={HEIGHT - PAD_B} className={css.axis} />
|
|
338
|
+
{/* series */}
|
|
339
|
+
{sampled.map(({ series: s, points }, i) => renderSeries(s, i, points))}
|
|
340
|
+
</svg>
|
|
341
|
+
) : (
|
|
342
|
+
<div className={css.empty}>
|
|
343
|
+
{series.map((s, i) => <div key={i} className={css.emptyRow}>{s.expr} — 无法绘制(表达式无效或范围非法)</div>)}
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
{hasParams && (
|
|
347
|
+
<div className={css.sliders}>
|
|
348
|
+
<div className={css.slidersHead}>
|
|
349
|
+
<span className={css.slidersTitle}>参数调节</span>
|
|
350
|
+
<button
|
|
351
|
+
type="button"
|
|
352
|
+
className={css.resetBtn}
|
|
353
|
+
onClick={() => {
|
|
354
|
+
setPlaying(false)
|
|
355
|
+
const reset: Record<string, number> = {}
|
|
356
|
+
for (const [si, s] of series.entries()) {
|
|
357
|
+
for (const p of s.params ?? []) {
|
|
358
|
+
if (p !== null && p !== undefined) reset[`${si}:${p.name}`] = p.value
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
setParams(reset)
|
|
362
|
+
setAnimProgress(0)
|
|
363
|
+
}}
|
|
364
|
+
>
|
|
365
|
+
↺ 重置
|
|
366
|
+
</button>
|
|
367
|
+
</div>
|
|
368
|
+
{series.map((s, si) => (s.params ?? []).map(p => {
|
|
369
|
+
if (p === null || p === undefined) return null
|
|
370
|
+
const key = `${si}:${p.name}`
|
|
371
|
+
const value = params[key] ?? p.value
|
|
372
|
+
return (
|
|
373
|
+
<label key={key} className={css.sliderRow}>
|
|
374
|
+
<span className={css.sliderName}>{s.label ?? s.expr} · {p.name}</span>
|
|
375
|
+
<input
|
|
376
|
+
type="range"
|
|
377
|
+
className={css.slider}
|
|
378
|
+
min={p.min ?? 0}
|
|
379
|
+
max={p.max ?? 10}
|
|
380
|
+
step={p.step ?? 0.1}
|
|
381
|
+
value={value}
|
|
382
|
+
onChange={e => {
|
|
383
|
+
// Read the value synchronously: React 18 may null out
|
|
384
|
+
// currentTarget after the handler in concurrent renders.
|
|
385
|
+
const next = Number(e.currentTarget.value)
|
|
386
|
+
setParams(prev => ({ ...prev, [key]: next }))
|
|
387
|
+
}}
|
|
388
|
+
/>
|
|
389
|
+
<span className={css.sliderValue}>{Math.round(value * 100) / 100}</span>
|
|
390
|
+
</label>
|
|
391
|
+
)
|
|
392
|
+
}))}
|
|
393
|
+
</div>
|
|
394
|
+
)}
|
|
395
|
+
{animParam !== null && (
|
|
396
|
+
<div className={css.animBar}>
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
className={css.playBtn}
|
|
400
|
+
onClick={() => {
|
|
401
|
+
if (playing) { setPlaying(false) }
|
|
402
|
+
else {
|
|
403
|
+
// Reset to the declared start value before playing.
|
|
404
|
+
setParams(prev => ({ ...prev, [`${animParam.si}:${animParam.param.name}`]: animParam.param.value }))
|
|
405
|
+
setAnimProgress(0)
|
|
406
|
+
setPlaying(true)
|
|
407
|
+
}
|
|
408
|
+
}}
|
|
409
|
+
>
|
|
410
|
+
{playing ? '⏸ 暂停' : '▶ 播放动画'}
|
|
411
|
+
</button>
|
|
412
|
+
{playing && (
|
|
413
|
+
<div className={css.animTrack}>
|
|
414
|
+
<div className={css.animFill} style={{ width: `${animProgress * 100}%` }} />
|
|
415
|
+
</div>
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
419
|
+
{(series.length > 1 || hasParams) && (
|
|
420
|
+
<div className={css.legend}>
|
|
421
|
+
{series.map((s, i) => (
|
|
422
|
+
<span key={i} className={css.legendItem}>
|
|
423
|
+
<span className={css.legendSwatch} style={{ background: seriesColor(i, series.length, s.color) ?? 'var(--dsw-alias-state-business-primary, #4f8ef7)' }} />
|
|
424
|
+
{s.label ?? s.expr}
|
|
425
|
+
</span>
|
|
426
|
+
))}
|
|
427
|
+
</div>
|
|
428
|
+
)}
|
|
429
|
+
</div>
|
|
430
|
+
)
|
|
431
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GenUI action context, plugin-owned.
|
|
3
|
+
*
|
|
4
|
+
* Contract hosts (`dsh-host-0812-contract` and later) ship the same context
|
|
5
|
+
* in `@deepseek-ai/dsh-client-ui-primitives` and MarkdownText installs the
|
|
6
|
+
* provider around fence renders — the renderer MUST consume that exact
|
|
7
|
+
* context instance so the host's action wiring reaches our components.
|
|
8
|
+
* Pristine hosts (the stock snapshot line) export no such symbol; the DOM
|
|
9
|
+
* render channel then falls back to the local context and installs its own
|
|
10
|
+
* provider (this package wraps every tree it mounts).
|
|
11
|
+
*
|
|
12
|
+
* Absent provider = v1 behavior: interactive components are display-only
|
|
13
|
+
* (their `action` is ignored). The hook never throws.
|
|
14
|
+
*/
|
|
15
|
+
import { createContext, useContext } from 'react'
|
|
16
|
+
import type { Context } from 'react'
|
|
17
|
+
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
|
18
|
+
|
|
19
|
+
/** v2 action handler: component action + its collected data. */
|
|
20
|
+
export type GenuiActionHandler = (action: string, payload: Record<string, unknown>) => void
|
|
21
|
+
|
|
22
|
+
/** Local fallback context (pristine hosts). */
|
|
23
|
+
const localContext = createContext<GenuiActionHandler | undefined>(undefined)
|
|
24
|
+
|
|
25
|
+
/** Host-provided context when the deployment ships it, else the local one. */
|
|
26
|
+
export const GenuiActionContext: Context<GenuiActionHandler | undefined> =
|
|
27
|
+
(primitives as unknown as { GenuiActionContext?: Context<GenuiActionHandler | undefined> }).GenuiActionContext ?? localContext
|
|
28
|
+
|
|
29
|
+
/** Read the installed action handler, if any. */
|
|
30
|
+
export function useGenuiAction(): GenuiActionHandler | undefined {
|
|
31
|
+
return useContext(GenuiActionContext)
|
|
32
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ECharts asset-bundle entry: registers the echarts engine on
|
|
3
|
+
* `window.__GenuiAssets__.echarts`. Built as a standalone IIFE into
|
|
4
|
+
* `lib/assets/echarts.js` and served by the plugin's node-half route; loaded
|
|
5
|
+
* on demand by echarts-lazy when a spec contains an `echart` node.
|
|
6
|
+
* @module @changfenhuang/dsh-genui/client/asset-echarts
|
|
7
|
+
*/
|
|
8
|
+
import { init as echartsInit, type EChartsType, type EChartsCoreOption } from 'echarts'
|
|
9
|
+
|
|
10
|
+
/** The engine surface registered by the echarts asset bundle. */
|
|
11
|
+
export interface EChartsAssetApi {
|
|
12
|
+
/** Create an ECharts instance on `el`, apply `option`, return the instance. */
|
|
13
|
+
createChart: (el: HTMLElement, option: EChartsCoreOption, opts?: { height?: number }) => EChartsType
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createChart(el: HTMLElement, option: EChartsCoreOption, opts?: { height?: number }): EChartsType {
|
|
17
|
+
const initOpts = opts !== undefined && opts.height !== undefined ? { height: opts.height } : undefined
|
|
18
|
+
const instance = echartsInit(el, undefined, initOpts)
|
|
19
|
+
instance.setOption(option)
|
|
20
|
+
return instance
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const win = globalThis as unknown as { __GenuiAssets__?: Record<string, unknown> }
|
|
24
|
+
const assets = win.__GenuiAssets__ ?? (win.__GenuiAssets__ = {})
|
|
25
|
+
assets.echarts = { createChart }
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset-bundle loader: fetches one of the plugin's lazy engine bundles
|
|
3
|
+
* (`lib/assets/mermaid.js` / `lib/assets/three.js`) by script injection and
|
|
4
|
+
* hands back the engine surface the bundle registered on
|
|
5
|
+
* `window.__GenuiAssets__`.
|
|
6
|
+
*
|
|
7
|
+
* The bundles are served by the plugin's OWN node-half HTTP route
|
|
8
|
+
* (`/plugins/@changfenhuang/dsh-genui/assets/*`), registered through the host
|
|
9
|
+
* webserver service — no host source change needed, and the longest-prefix
|
|
10
|
+
* rule lets this route win over the generic `/plugins` bundle route. Each
|
|
11
|
+
* file is loaded at most once per page (promise memoized); a rejection is
|
|
12
|
+
* final for the page (the node's fallback UI takes over).
|
|
13
|
+
*
|
|
14
|
+
* The rev query rides the boot graph: the plugin's own row in
|
|
15
|
+
* `window.__DSH_BOOT__` carries the client bundle rev, so a plugin rebuild
|
|
16
|
+
* busts the asset cache together with the main bundle. Absent graph = no
|
|
17
|
+
* query; the route serves no-cache anyway.
|
|
18
|
+
* @module @changfenhuang/dsh-genui/client/asset-loader
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The plugin's entry id — mirrors the loader entry name (package name). */
|
|
22
|
+
const PLUGIN_ID = '@changfenhuang/dsh-genui'
|
|
23
|
+
|
|
24
|
+
/** Assets directory served by the node-half route. */
|
|
25
|
+
const ASSET_DIR = `/plugins/${PLUGIN_ID}/assets`
|
|
26
|
+
|
|
27
|
+
/** Boot graph shape read from `window.__DSH_BOOT__` (subset, defensive). */
|
|
28
|
+
interface BootGraphLike {
|
|
29
|
+
entries?: Array<{ id?: string; rev?: string }>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The global the asset bundles register their engines on. */
|
|
33
|
+
interface AssetGlobal {
|
|
34
|
+
__GenuiAssets__?: Record<string, unknown>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolve an asset URL, appending the bundle rev for cache busting when the
|
|
38
|
+
* boot graph exposes it. */
|
|
39
|
+
export function assetUrl(file: string): string {
|
|
40
|
+
const graph = (window as unknown as { __DSH_BOOT__?: BootGraphLike }).__DSH_BOOT__
|
|
41
|
+
const rev = graph?.entries?.find(entry => entry.id === PLUGIN_ID)?.rev
|
|
42
|
+
return `${ASSET_DIR}/${file}${rev === undefined ? '' : `?rev=${rev}`}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const pending = new Map<string, Promise<Record<string, unknown>>>()
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Load one asset bundle and return the engines it registered. Memoized per
|
|
49
|
+
* file: repeated requests (several mermaid nodes, re-renders) share one
|
|
50
|
+
* script load; a failed load stays failed for the page (the component shows
|
|
51
|
+
* its fallback).
|
|
52
|
+
* @param name - 'mermaid', 'three', or 'echarts'.
|
|
53
|
+
* @returns the registered engine surface.
|
|
54
|
+
*/
|
|
55
|
+
export function loadGenuiAsset<T>(name: 'mermaid' | 'three' | 'echarts'): Promise<T> {
|
|
56
|
+
const file = `${name}.js`
|
|
57
|
+
const existing = pending.get(file)
|
|
58
|
+
if (existing !== undefined) return existing as Promise<T>
|
|
59
|
+
const task = new Promise<T>((resolve, reject) => {
|
|
60
|
+
const script = document.createElement('script')
|
|
61
|
+
script.src = assetUrl(file)
|
|
62
|
+
script.async = true
|
|
63
|
+
script.onload = () => {
|
|
64
|
+
const global = (window as unknown as AssetGlobal).__GenuiAssets__ ?? {}
|
|
65
|
+
const api = global[name]
|
|
66
|
+
if (api === undefined) {
|
|
67
|
+
reject(new Error(`genui asset '${file}' loaded but registered no '${name}' engine`))
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
resolve(api as T)
|
|
71
|
+
}
|
|
72
|
+
script.onerror = () => {
|
|
73
|
+
reject(new Error(`genui asset '${file}' failed to load (host asset route missing?)`))
|
|
74
|
+
}
|
|
75
|
+
document.head.appendChild(script)
|
|
76
|
+
})
|
|
77
|
+
pending.set(file, task as unknown as Promise<Record<string, unknown>>)
|
|
78
|
+
return task
|
|
79
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid asset-bundle entry: registers the mermaid engine on
|
|
3
|
+
* `window.__GenuiAssets__.mermaid`. Built as a standalone IIFE into
|
|
4
|
+
* `lib/assets/mermaid.js` and served by the plugin's node-half route; loaded
|
|
5
|
+
* on demand by mermaid-lazy.
|
|
6
|
+
* @module @changfenhuang/dsh-genui/client/asset-mermaid
|
|
7
|
+
*/
|
|
8
|
+
import { renderMermaid } from './mermaid-core.ts'
|
|
9
|
+
import { assertSafeSvg, repairMermaidSource } from './mermaid-safe.ts'
|
|
10
|
+
|
|
11
|
+
const win = globalThis as unknown as { __GenuiAssets__?: Record<string, unknown> }
|
|
12
|
+
const assets = win.__GenuiAssets__ ?? (win.__GenuiAssets__ = {})
|
|
13
|
+
assets.mermaid = { assertSafeSvg, repairMermaidSource, renderMermaid }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* three.js asset-bundle entry: registers the scene renderer on
|
|
3
|
+
* `window.__GenuiAssets__.three`. Built as a standalone IIFE into
|
|
4
|
+
* `lib/assets/three.js` and served by the plugin's node-half route; loaded on
|
|
5
|
+
* demand by scene3d-lazy.
|
|
6
|
+
* @module @changfenhuang/dsh-genui/client/asset-three
|
|
7
|
+
*/
|
|
8
|
+
import { mountScene } from './scene3d-core.ts'
|
|
9
|
+
|
|
10
|
+
const win = globalThis as unknown as { __GenuiAssets__?: Record<string, unknown> }
|
|
11
|
+
const assets = win.__GenuiAssets__ ?? (win.__GenuiAssets__ = {})
|
|
12
|
+
assets.three = { mountScene }
|