@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.
Files changed (106) hide show
  1. package/CHANGELOG.md +367 -0
  2. package/LICENSE +21 -0
  3. package/README.md +261 -0
  4. package/README.zh-CN.md +261 -0
  5. package/SKILL.md +115 -0
  6. package/cordis.patch.yml +9 -0
  7. package/demo-prompts.md +118 -0
  8. package/lib/assets/echarts.js +64 -0
  9. package/lib/assets/mermaid.js +3591 -0
  10. package/lib/assets/three.js +3844 -0
  11. package/lib/client.js +5 -0
  12. package/lib/index.js +1895 -0
  13. package/lib/invariant.js +19 -0
  14. package/lib/types/client/EChartNode.d.ts +4 -0
  15. package/lib/types/client/ErrorBoundary.d.ts +30 -0
  16. package/lib/types/client/GenuiBlock.d.ts +7 -0
  17. package/lib/types/client/PlotBlock.d.ts +44 -0
  18. package/lib/types/client/action-context.d.ts +7 -0
  19. package/lib/types/client/asset-echarts.d.ts +15 -0
  20. package/lib/types/client/asset-loader.d.ts +31 -0
  21. package/lib/types/client/asset-mermaid.d.ts +1 -0
  22. package/lib/types/client/asset-three.d.ts +1 -0
  23. package/lib/types/client/blocks/advanced.d.ts +104 -0
  24. package/lib/types/client/blocks/basic.d.ts +27 -0
  25. package/lib/types/client/blocks/charts.d.ts +38 -0
  26. package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
  27. package/lib/types/client/blocks/diagram/index.d.ts +5 -0
  28. package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
  29. package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
  30. package/lib/types/client/blocks/forms.d.ts +83 -0
  31. package/lib/types/client/blocks/render-node.d.ts +10 -0
  32. package/lib/types/client/blocks/state.d.ts +58 -0
  33. package/lib/types/client/dom-fence.d.ts +14 -0
  34. package/lib/types/client/echarts-lazy.d.ts +17 -0
  35. package/lib/types/client/fence-render.d.ts +58 -0
  36. package/lib/types/client/gallery.d.ts +9 -0
  37. package/lib/types/client/guard.d.ts +126 -0
  38. package/lib/types/client/index.d.ts +36 -0
  39. package/lib/types/client/interaction-store.d.ts +43 -0
  40. package/lib/types/client/mermaid-core.d.ts +8 -0
  41. package/lib/types/client/mermaid-lazy.d.ts +9 -0
  42. package/lib/types/client/mermaid-safe.d.ts +52 -0
  43. package/lib/types/client/panel-command.d.ts +29 -0
  44. package/lib/types/client/panel-store.d.ts +94 -0
  45. package/lib/types/client/panel.d.ts +15 -0
  46. package/lib/types/client/parse-partial.d.ts +37 -0
  47. package/lib/types/client/safe-math.d.ts +43 -0
  48. package/lib/types/client/scene3d-core.d.ts +21 -0
  49. package/lib/types/client/scene3d-lazy.d.ts +8 -0
  50. package/lib/types/client/spec.d.ts +610 -0
  51. package/lib/types/client/toolview.d.ts +7 -0
  52. package/lib/types/index.d.ts +1 -0
  53. package/lib/types/plugin/index.d.ts +25 -0
  54. package/lib/types/plugin/invariant.d.ts +15 -0
  55. package/lib/types/plugin/tool.d.ts +27 -0
  56. package/lib/types/shared/fence-repair.d.ts +67 -0
  57. package/package.json +121 -0
  58. package/src/client/EChartNode.tsx +242 -0
  59. package/src/client/ErrorBoundary.tsx +66 -0
  60. package/src/client/GenuiBlock.module.css +1263 -0
  61. package/src/client/GenuiBlock.tsx +166 -0
  62. package/src/client/PlotBlock.module.css +155 -0
  63. package/src/client/PlotBlock.tsx +431 -0
  64. package/src/client/action-context.ts +32 -0
  65. package/src/client/asset-echarts.ts +25 -0
  66. package/src/client/asset-loader.ts +79 -0
  67. package/src/client/asset-mermaid.ts +13 -0
  68. package/src/client/asset-three.ts +12 -0
  69. package/src/client/blocks/advanced.tsx +435 -0
  70. package/src/client/blocks/basic.tsx +116 -0
  71. package/src/client/blocks/charts.tsx +319 -0
  72. package/src/client/blocks/diagram/geometry.ts +151 -0
  73. package/src/client/blocks/diagram/index.tsx +284 -0
  74. package/src/client/blocks/diagram/layout.ts +199 -0
  75. package/src/client/blocks/diagram/theme.ts +108 -0
  76. package/src/client/blocks/forms.tsx +523 -0
  77. package/src/client/blocks/render-node.tsx +260 -0
  78. package/src/client/blocks/state.ts +61 -0
  79. package/src/client/dom-fence.tsx +641 -0
  80. package/src/client/echarts-lazy.ts +35 -0
  81. package/src/client/fence-render.tsx +230 -0
  82. package/src/client/gallery.ts +128 -0
  83. package/src/client/guard.ts +1450 -0
  84. package/src/client/index.tsx +195 -0
  85. package/src/client/interaction-store.ts +122 -0
  86. package/src/client/mermaid-core.ts +121 -0
  87. package/src/client/mermaid-lazy.ts +31 -0
  88. package/src/client/mermaid-safe.ts +122 -0
  89. package/src/client/panel-command.ts +119 -0
  90. package/src/client/panel-store.ts +471 -0
  91. package/src/client/panel.tsx +185 -0
  92. package/src/client/parse-partial.ts +162 -0
  93. package/src/client/safe-math.ts +270 -0
  94. package/src/client/scene3d-core.ts +192 -0
  95. package/src/client/scene3d-lazy.ts +26 -0
  96. package/src/client/spec.ts +767 -0
  97. package/src/client/toolview.tsx +66 -0
  98. package/src/css-modules.d.ts +5 -0
  99. package/src/index.ts +12 -0
  100. package/src/plugin/index.ts +173 -0
  101. package/src/plugin/invariant.ts +30 -0
  102. package/src/plugin/node-shims.d.ts +31 -0
  103. package/src/plugin/tool.ts +330 -0
  104. package/src/shared/fence-repair.ts +244 -0
  105. package/tsconfig.json +35 -0
  106. package/tsdown.config.ts +193 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Session GenUI panel: the dock surface that renders the latest `render_ui`
3
+ * spec IN PLACE. The toolview publishes every new result into the panel
4
+ * store; this component subscribes and re-renders the same block, so a
5
+ * refresh (model calls render_ui again) updates the panel instead of the
6
+ * message flow accumulating new UI per round.
7
+ *
8
+ * Mounted through the 'conversation.input.dock' list slot (TodoDock posture)
9
+ * — a session-scoped, always-present seat above the composer — so the panel
10
+ * coexists with the message flow on one screen. Absent spec = no panel.
11
+ *
12
+ * The dock is COLLAPSIBLE (TodoDock pattern), defaulting to a one-line
13
+ * header: a tall always-open panel pins above the composer and crushes the
14
+ * message flow's visible area, making history feel unreachable. Collapsed,
15
+ * it takes a single row; expanded, the body caps its height and scrolls
16
+ * internally, so the conversation stays scrollable either way.
17
+ *
18
+ * The expanded body is vertically RESIZABLE: a drag strip between the header
19
+ * and the body adjusts the body height (clamped to sane bounds); the custom
20
+ * height survives expand/collapse cycles for the session's life.
21
+ *
22
+ * Actions ride the same GenuiActionContext contract as inline fences: the
23
+ * sendGenuiAction injection (built from the scoped conversation service in
24
+ * apply) queues a [genui-action] user message back to the model.
25
+ */
26
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
27
+ import type {} from '@deepseek-ai/dsh-client-runtime/client'
28
+ import { GenuiActionContext, type GenuiActionHandler } from './action-context.ts'
29
+ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
30
+ import { GenuiBlock } from './GenuiBlock.tsx'
31
+ import { ErrorBoundary } from './ErrorBoundary.tsx'
32
+ import { panelStateKey } from './interaction-store.ts'
33
+ import { clearSessionPanel, getPanelExpandToken, getPanelSpec, setLocalPanel, subscribePanel, subscribePanelExpand } from './panel-store.ts'
34
+ import css from './GenuiBlock.module.css'
35
+
36
+ /** Resize bounds for the panel body, in px. */
37
+ const PANEL_HEIGHT_MIN = 120
38
+ const PANEL_HEIGHT_MAX = 600
39
+
40
+ /** Injection face built per session in apply (scoped conversation send). */
41
+ export interface GenuiPanelInjected {
42
+ sessionId: string
43
+ sendGenuiAction: GenuiActionHandler
44
+ }
45
+
46
+ export type GenuiPanelProps = PropsRuntime<'conversation.input.dock'> & GenuiPanelInjected
47
+
48
+ /**
49
+ * Panel dock entry. Renders nothing until the session's toolview published a
50
+ * spec; afterwards the SAME block re-renders on every publish. Collapsed by
51
+ * default so the dock never steals the message flow's scroll room; the
52
+ * header always shows the current panel title.
53
+ */
54
+ export function GenuiPanel({ sessionId, sendGenuiAction }: GenuiPanelProps) {
55
+ const spec = useSyncExternalStore(subscribePanel, () => getPanelSpec(sessionId))
56
+ const expandToken = useSyncExternalStore(subscribePanelExpand, () => getPanelExpandToken(sessionId))
57
+ const [collapsed, setCollapsed] = useState(true)
58
+ // Custom body height from the resize drag; null = the CSS default (360px).
59
+ const [bodyHeight, setBodyHeight] = useState<number | null>(null)
60
+ const [resizing, setResizing] = useState(false)
61
+ const dragStart = useRef<{ y: number; height: number } | null>(null)
62
+ const bodyRef = useRef<HTMLDivElement | null>(null)
63
+
64
+ // Explicit expand requests (the /panel command) open the dock even when the
65
+ // user collapsed it manually: the monotone token guarantees a fresh request
66
+ // is never mistaken for the previous one.
67
+ useEffect(() => {
68
+ if (expandToken > 0) setCollapsed(false)
69
+ }, [expandToken])
70
+
71
+ // Session teardown: the dock is session-scoped, so unmount means the host
72
+ // pruned or destroyed the session's scope. Release the session's panel
73
+ // state (snapshot, ops, overflow diagnostics, expand token) — without this
74
+ // the module-level store grows one entry per session for the app's whole
75
+ // lifetime. A reopen lazily rebuilds from history replays, which is the
76
+ // host's own lifecycle contract.
77
+ useEffect(() => {
78
+ return () => {
79
+ clearSessionPanel(sessionId)
80
+ }
81
+ }, [sessionId])
82
+
83
+ // Cleanup safety net: if the component unmounts mid-drag (spec cleared),
84
+ // the window listeners must not outlive it.
85
+ useEffect(() => {
86
+ if (!resizing) return
87
+ return () => {
88
+ dragStart.current = null
89
+ }
90
+ }, [resizing])
91
+
92
+ /** Start a vertical resize drag on the panel's top edge. Dragging UP grows
93
+ * the panel (the edge moves toward the message flow); dragging down shrinks
94
+ * it. Pointer capture binds move/up/cancel to the HANDLE, so dragging past
95
+ * the element edge keeps tracking and nothing global is registered — no
96
+ * window listeners, nothing to leak on unmount. */
97
+ const startResize = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
98
+ e.preventDefault()
99
+ const handle = e.currentTarget
100
+ // Track from the current committed height (state mirrors the drag, the
101
+ // CSS default otherwise) — no DOM measurement needed.
102
+ const startHeight = bodyHeight ?? 360
103
+ dragStart.current = { y: e.clientY, height: startHeight }
104
+ setResizing(true)
105
+ try {
106
+ handle.setPointerCapture(e.pointerId)
107
+ } catch {
108
+ // jsdom / capture unsupported: listeners below still track the drag.
109
+ }
110
+ const onMove = (ev: PointerEvent): void => {
111
+ const start = dragStart.current
112
+ if (start === null) return
113
+ // Upward drag (clientY decreasing) grows the body height.
114
+ const next = Math.min(PANEL_HEIGHT_MAX, Math.max(PANEL_HEIGHT_MIN, start.height + (start.y - ev.clientY)))
115
+ setBodyHeight(next)
116
+ }
117
+ const onUp = (): void => {
118
+ dragStart.current = null
119
+ setResizing(false)
120
+ handle.removeEventListener('pointermove', onMove)
121
+ handle.removeEventListener('pointerup', onUp)
122
+ handle.removeEventListener('pointercancel', onUp)
123
+ }
124
+ handle.addEventListener('pointermove', onMove)
125
+ handle.addEventListener('pointerup', onUp)
126
+ handle.addEventListener('pointercancel', onUp)
127
+ }, [bodyHeight])
128
+
129
+ if (spec === null || spec.items.length === 0) return null
130
+ return (
131
+ <div className={css.panel} data-genui-panel>
132
+ {/* Resize grip on the panel's TOP EDGE (above the header): the drag
133
+ target the user described — the outermost top border, not a strip
134
+ under the title. */}
135
+ {!collapsed && (
136
+ <div
137
+ role="separator"
138
+ aria-orientation="horizontal"
139
+ aria-label="调整面板高度"
140
+ className={`${css.panelResizeHandle}${resizing ? ` ${css.panelResizeHandleActive}` : ''}`}
141
+ onPointerDown={startResize}
142
+ />
143
+ )}
144
+ <div className={css.panelHeader}>
145
+ <button
146
+ type="button"
147
+ className={css.panelToggle}
148
+ aria-expanded={!collapsed}
149
+ onClick={() => setCollapsed(c => !c)}
150
+ >
151
+ <span className={css.panelBadge}>面板</span>
152
+ <span className={css.panelTitle}>{spec.title ?? 'GenUI 面板'}</span>
153
+ <span className={css.panelChevron} aria-hidden>{collapsed ? '▸' : '▾'}</span>
154
+ </button>
155
+ {/* In-place dismiss (issue #23): the same local override `/panel
156
+ clear` applies — persists to localStorage, notifies subscribers,
157
+ unmounts the dock without any navigation or reload. */}
158
+ <button
159
+ type="button"
160
+ className={css.panelClose}
161
+ aria-label="关闭面板"
162
+ title="关闭面板"
163
+ onClick={() => setLocalPanel(sessionId, null)}
164
+ >
165
+ <span aria-hidden>✕</span>
166
+ </button>
167
+ </div>
168
+ {!collapsed && (
169
+ <div
170
+ ref={bodyRef}
171
+ className={css.panelBody}
172
+ data-genui-panel-body
173
+ style={bodyHeight === null ? undefined : { height: bodyHeight }}
174
+ >
175
+ <GenuiActionContext.Provider value={sendGenuiAction}>
176
+ <ErrorBoundary label="面板">
177
+ {/* content-fingerprinted: same panel spec re-published restores its state */}
178
+ <GenuiBlock spec={spec} stateKey={panelStateKey(sessionId, JSON.stringify(spec))} />
179
+ </ErrorBoundary>
180
+ </GenuiActionContext.Provider>
181
+ </div>
182
+ )}
183
+ </div>
184
+ )
185
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Partial GenUI spec parsing: extract the components that are already
3
+ * complete from a still-growing ```dsh-ui fence body, so the UI can render
4
+ * top-down as the model writes — each finished component appears the moment
5
+ * its JSON object closes, instead of the whole block waiting for the fence.
6
+ *
7
+ * Strategy (tolerant, white-list-agnostic, BOUNDED):
8
+ * 1. Full parse first (the common settled case) — at most one.
9
+ * 2. ONE left-to-right scan collects the repair candidates: every position
10
+ * where the prefix is bracket-balanced (trailing comma / fence tail) and
11
+ * every `}` object close within the depth budget (an unfinished trailing
12
+ * element, closed by appending the remaining brackets). The ring buffer
13
+ * keeps only the longest-direction candidates (≤ MAX_PARTIAL_REPAIR_ATTEMPTS),
14
+ * so the work is O(n) with a hard parse-attempt cap — a pathological
15
+ * input can never re-scan prefixes or burn seconds in JSON.parse.
16
+ * 3. Candidates are tried longest first; the first that parses as a GenUI
17
+ * spec wins. Nothing parses → null (streaming partial, wait for more).
18
+ *
19
+ * The result is only ever a PREFIX of the intended spec, so it is always
20
+ * safe to render: components already present are complete and valid.
21
+ *
22
+ * `ponytail:` the 32-candidate / 33-parse budget is the protection ceiling
23
+ * for depth 8 / 200 nodes; only real streaming samples proving a recovery
24
+ * shortfall justify switching to a tokenizing parser.
25
+ */
26
+ import { GENUI_LIMITS } from './guard.ts'
27
+ import { isGenuiSpec, type GenuiSpec } from './spec.ts'
28
+ import { wrapSingleComponentRoot } from './spec.ts'
29
+
30
+ /** Default repair-candidate budget (adjustable; see the design doc). */
31
+ export const MAX_PARTIAL_REPAIR_ATTEMPTS = 32
32
+
33
+ let repairAttemptsLimit = MAX_PARTIAL_REPAIR_ATTEMPTS
34
+
35
+ /** Override the repair-candidate budget (tests / tuning). */
36
+ export function setMaxPartialRepairAttempts(n: number): void {
37
+ repairAttemptsLimit = n
38
+ }
39
+
40
+ /** One repair candidate: a balanced prefix of the body ending at `end`,
41
+ * plus the closing brackets to append (empty when already balanced). */
42
+ export interface PartialCandidate {
43
+ end: number
44
+ closingSuffix: string
45
+ }
46
+
47
+ /** Single left-to-right pass over the raw body. Tracks the bracket stack
48
+ * (skipping strings/escapes correctly) and records:
49
+ * - every position where the prefix is fully balanced (trailing comma or
50
+ * fence tail) — candidate with an empty closing suffix;
51
+ * - every `}` object close whose remaining depth fits the spec budget —
52
+ * candidate with the remaining brackets closed (an unfinished trailing
53
+ * element).
54
+ * Candidates are ring-buffered to the attempts budget (the LAST pushes are
55
+ * the longest), then returned longest-first, deduplicated by end. The scan
56
+ * stops at the first unbalanced close — earlier balanced prefixes remain
57
+ * valid candidates.
58
+ *
59
+ * Exposed for tests (the `scannedChars` diagnostic); not exported from the
60
+ * package entry. The parser calls this exactly once per parse.
61
+ */
62
+ export function collectPartialCandidates(raw: string): { candidates: PartialCandidate[]; scannedChars: number } {
63
+ const stack: string[] = []
64
+ const candidates: PartialCandidate[] = []
65
+ const push = (c: PartialCandidate): void => {
66
+ if (candidates.length >= repairAttemptsLimit) candidates.shift()
67
+ candidates.push(c)
68
+ }
69
+ let inString = false
70
+ let escaped = false
71
+ let scanned = 0
72
+ for (; scanned < raw.length; scanned++) {
73
+ const ch = raw[scanned]!
74
+ if (inString) {
75
+ if (escaped) escaped = false
76
+ else if (ch === '\\') escaped = true
77
+ else if (ch === '"') inString = false
78
+ continue
79
+ }
80
+ if (ch === '"') {
81
+ inString = true
82
+ continue
83
+ }
84
+ if (ch === '{' || ch === '[') {
85
+ stack.push(ch)
86
+ continue
87
+ }
88
+ if (ch === '}' || ch === ']') {
89
+ const open = stack.pop()
90
+ const expects = ch === '}' ? '{' : '['
91
+ if (open !== expects) {
92
+ // Unbalanced (mismatched or stray close): the tail is unusable, but
93
+ // balanced prefixes recorded earlier stay valid. Stop scanning.
94
+ break
95
+ }
96
+ if (ch === '}' && stack.length <= GENUI_LIMITS.maxDepth) {
97
+ // A finished object element: closing the remaining brackets yields
98
+ // the longest legal prefix (an unfinished trailing element drops).
99
+ push({ end: scanned + 1, closingSuffix: closeSuffix(stack) })
100
+ }
101
+ if (stack.length === 0) {
102
+ // Whole prefix balanced: a complete-JSON candidate (trailing comma /
103
+ // fence tail cases).
104
+ push({ end: scanned + 1, closingSuffix: '' })
105
+ }
106
+ continue
107
+ }
108
+ }
109
+ candidates.sort((a, b) => b.end - a.end)
110
+ const deduped: PartialCandidate[] = []
111
+ for (const c of candidates) {
112
+ if (deduped.length === 0 || deduped[deduped.length - 1]!.end !== c.end) deduped.push(c)
113
+ }
114
+ return { candidates: deduped.slice(0, repairAttemptsLimit), scannedChars: scanned }
115
+ }
116
+
117
+ /** Closing brackets for the remaining open stack (top first). */
118
+ function closeSuffix(stack: string[]): string {
119
+ let suffix = ''
120
+ for (let i = stack.length - 1; i >= 0; i--) suffix += stack[i] === '{' ? '}' : ']'
121
+ return suffix
122
+ }
123
+
124
+ /** Try to parse a candidate as a GenuiSpec. */
125
+ function trySpec(candidate: string): GenuiSpec | null {
126
+ try {
127
+ const value: unknown = JSON.parse(candidate)
128
+ if (isGenuiSpec(value)) return value
129
+ // Single-component roots are part of the documented fence vocabulary
130
+ // (e.g. a bare {"type":"callout",…} body) — wrap into a col so the
131
+ // items-gated pipeline renders them (panel/append hoisted).
132
+ return wrapSingleComponentRoot(value)
133
+ } catch {
134
+ return null
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Parse a possibly incomplete genui spec body.
140
+ * @param raw - the fence body as accumulated so far.
141
+ * @returns a spec containing only finished components, or null when nothing
142
+ * usable has been written yet.
143
+ */
144
+ export function parsePartialGenuiSpec(raw: string): GenuiSpec | null {
145
+ const text = raw.trim()
146
+ if (text === '') return null
147
+
148
+ // 1. Full parse (settled / already-complete body).
149
+ const full = trySpec(text)
150
+ if (full !== null) return full
151
+
152
+ // 2. Bounded repair: ONE forward scan, at most `repairAttemptsLimit`
153
+ // candidates, longest first — never a re-scan per `}`.
154
+ const { candidates } = collectPartialCandidates(text)
155
+ for (const candidate of candidates) {
156
+ const spec = trySpec(text.slice(0, candidate.end) + candidate.closingSuffix)
157
+ if (spec !== null) return spec
158
+ }
159
+
160
+ // 3. Not even one complete element yet (e.g. `{"items":[{"type":"tex`).
161
+ return null
162
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * SafeMath: a restricted math expression evaluator for GenUI plot specs.
3
+ *
4
+ * The model supplies an expression string like "sin(x) * 2 + 1". It is parsed
5
+ * and evaluated here WITHOUT eval / new Function: a hand-written recursive
6
+ * descent parser over a white-listed function/constant vocabulary. Unknown
7
+ * identifiers, object property access, and call syntax other than the
8
+ * white-listed functions are parse errors, so an adversarial expression
9
+ * cannot reach globals, prototypes, or side effects.
10
+ *
11
+ * Supported grammar:
12
+ * expr := term (('+' | '-') term)*
13
+ * term := unary (('*' | '/' | '%') unary)*
14
+ * unary := ('-' | '+') unary | power
15
+ * power := atom ('^' atom)?
16
+ * atom := number | variable | constant | func '(' expr ')' | '(' expr ')'
17
+ * variable: 'x' (and any single-letter variable present in vars)
18
+ * constant: 'pi' | 'e' | 'tau'
19
+ * func : sin cos tan asin acos atan sqrt cbrt exp log ln abs floor ceil round min max pow
20
+ */
21
+
22
+ export interface SafeMathOptions {
23
+ /** Variable values; only names present here may be referenced as variables. */
24
+ vars?: Record<string, number>
25
+ }
26
+
27
+ const CONSTANTS: Record<string, number> = {
28
+ pi: Math.PI,
29
+ e: Math.E,
30
+ tau: Math.PI * 2,
31
+ }
32
+
33
+ const FUNCTIONS: Record<string, (...args: number[]) => number> = {
34
+ sin: Math.sin, cos: Math.cos, tan: Math.tan,
35
+ asin: Math.asin, acos: Math.acos, atan: Math.atan,
36
+ sqrt: Math.sqrt, cbrt: Math.cbrt,
37
+ exp: Math.exp, log: Math.log10, ln: Math.log,
38
+ abs: Math.abs, floor: Math.floor, ceil: Math.ceil, round: Math.round,
39
+ min: Math.min, max: Math.max,
40
+ pow: (a, b) => Math.pow(a, b),
41
+ }
42
+
43
+ class ParseError extends Error {
44
+ constructor(message: string, readonly pos: number) {
45
+ super(`SafeMath parse error at ${pos}: ${message}`)
46
+ }
47
+ }
48
+
49
+ class SafeMathParser {
50
+ private i = 0
51
+ constructor(private readonly src: string, private readonly vars: Record<string, number>) {}
52
+
53
+ parse(): (x: number) => number {
54
+ const node = this.parseExpr()
55
+ this.skipWs()
56
+ if (this.i < this.src.length) throw new ParseError('unexpected trailing input', this.i)
57
+ return this.evalNode(node)
58
+ }
59
+
60
+ private evalNode(node: number | ((x: number) => number)): (x: number) => number {
61
+ if (typeof node === 'number') return () => node
62
+ return node
63
+ }
64
+
65
+ private parseExpr(): number | ((x: number) => number) {
66
+ let left = this.parseTerm()
67
+ for (;;) {
68
+ this.skipWs()
69
+ const c = this.peek()
70
+ if (c === '+' || c === '-') {
71
+ this.i++
72
+ const right = this.parseTerm()
73
+ const op = c
74
+ const prev = left
75
+ left = op === '+'
76
+ ? (x) => this.asNum(prev, x) + this.asNum(right, x)
77
+ : (x) => this.asNum(prev, x) - this.asNum(right, x)
78
+ } else return left
79
+ }
80
+ }
81
+
82
+ private parseTerm(): number | ((x: number) => number) {
83
+ let left = this.parseUnary()
84
+ for (;;) {
85
+ this.skipWs()
86
+ const c = this.peek()
87
+ if (c === '*' || c === '/' || c === '%') {
88
+ this.i++
89
+ const right = this.parseUnary()
90
+ const op = c
91
+ const prev = left
92
+ left = op === '*'
93
+ ? (x) => this.asNum(prev, x) * this.asNum(right, x)
94
+ : op === '/'
95
+ ? (x) => this.asNum(prev, x) / this.asNum(right, x)
96
+ : (x) => this.asNum(prev, x) % this.asNum(right, x)
97
+ } else return left
98
+ }
99
+ }
100
+
101
+ private parseUnary(): number | ((x: number) => number) {
102
+ this.skipWs()
103
+ const c = this.char()
104
+ if (c === '-' || c === '+') {
105
+ this.i++
106
+ const operand = this.parseUnary()
107
+ return c === '-' ? (x) => -this.asNum(operand, x) : (x) => this.asNum(operand, x)
108
+ }
109
+ return this.parsePower()
110
+ }
111
+
112
+ private parsePower(): number | ((x: number) => number) {
113
+ const base = this.parseAtom()
114
+ this.skipWs()
115
+ if (this.peek() === '^') {
116
+ this.i++
117
+ const exp = this.parsePower()
118
+ const prev = base
119
+ return (x) => Math.pow(this.asNum(prev, x), this.asNum(exp, x))
120
+ }
121
+ return base
122
+ }
123
+
124
+ private parseAtom(): number | ((x: number) => number) {
125
+ this.skipWs()
126
+ const c = this.char()
127
+ if (c === '(') {
128
+ this.i++
129
+ const inner = this.parseExpr()
130
+ this.skipWs()
131
+ if (this.peek() !== ')') throw new ParseError('expected )', this.i)
132
+ this.i++
133
+ return inner
134
+ }
135
+ if (c >= '0' && c <= '9' || c === '.') return this.parseNumber()
136
+ if (this.isIdentStart(c)) return this.parseIdent()
137
+ throw new ParseError(`unexpected character '${c}'`, this.i)
138
+ }
139
+
140
+ private parseNumber(): number {
141
+ const start = this.i
142
+ while (this.i < this.src.length && /[0-9.eE+\-]/.test(this.src[this.i]!)) this.i++
143
+ const text = this.src.slice(start, this.i)
144
+ const value = Number(text)
145
+ if (Number.isNaN(value)) throw new ParseError(`invalid number '${text}'`, start)
146
+ return value
147
+ }
148
+
149
+ private parseIdent(): number | ((x: number) => number) {
150
+ const start = this.i
151
+ while (this.i < this.src.length && this.isIdentChar(this.src[this.i]!)) this.i++
152
+ const name = this.src.slice(start, this.i)
153
+ this.skipWs()
154
+ if (this.peek() === '(') {
155
+ // Function call.
156
+ const fn = FUNCTIONS[name]
157
+ if (fn === undefined) throw new ParseError(`unknown function '${name}'`, start)
158
+ this.i++
159
+ const args: Array<number | ((x: number) => number)> = []
160
+ this.skipWs()
161
+ if (this.peek() === ')') {
162
+ this.i++
163
+ } else {
164
+ for (;;) {
165
+ args.push(this.parseExpr())
166
+ this.skipWs()
167
+ const sep = this.peek()
168
+ if (sep === ',') { this.i++; continue }
169
+ if (sep === ')') { this.i++; break }
170
+ throw new ParseError('expected , or ) in call', this.i)
171
+ }
172
+ }
173
+ return (x) => fn(...args.map(a => this.asNum(a, x)))
174
+ }
175
+ // Own-property checks only: `in` would match inherited members like
176
+ // `constructor`/`toString` on the vars object.
177
+ if (Object.hasOwn(CONSTANTS, name)) return CONSTANTS[name]!
178
+ if (name === 'x') {
179
+ // The sampling variable resolves against the evaluation argument.
180
+ return (value: number) => value
181
+ }
182
+ if (Object.hasOwn(this.vars, name)) {
183
+ // A declared parameter (a/b/c…) resolves against its captured value,
184
+ // which the caller changes by recompiling with new vars.
185
+ const captured = this.vars[name]!
186
+ return () => captured
187
+ }
188
+ // Implicit parameters: an undeclared single-lowercase-letter identifier
189
+ // (a/b/k/m/n…) is a parameter that defaults to 1, so a model expression
190
+ // like "a*sin(b*x)" without a params declaration still renders instead
191
+ // of failing as "unknown identifier". This is safe: only [a-z] matches,
192
+ // so injection names (constructor, window, process…) still parse-error.
193
+ if (/^[a-z]$/.test(name)) return () => 1
194
+ throw new ParseError(`unknown identifier '${name}'`, start)
195
+ }
196
+
197
+ private asNum(node: number | ((x: number) => number), x: number): number {
198
+ return typeof node === 'number' ? node : node(x)
199
+ }
200
+
201
+ private skipWs(): void {
202
+ while (this.i < this.src.length && /\s/.test(this.src[this.i]!)) this.i++
203
+ }
204
+
205
+ private peek(): string | undefined {
206
+ return this.src[this.i]
207
+ }
208
+
209
+ private char(): string {
210
+ const c = this.peek()
211
+ if (c === undefined) throw new ParseError('unexpected end of input', this.i)
212
+ return c
213
+ }
214
+
215
+ private isIdentStart(c: string | undefined): boolean {
216
+ return c !== undefined && /[a-zA-Z_]/.test(c)
217
+ }
218
+
219
+ private isIdentChar(c: string): boolean {
220
+ return /[a-zA-Z0-9_]/.test(c)
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Compile an expression into an evaluable function.
226
+ * @param expr - the math expression. `x` is the sampling variable; any other
227
+ * name in `vars` is a parameter whose captured value the evaluator returns
228
+ * (recompile with new vars to change a parameter — expressions are short).
229
+ * @param options - parameter values; `x` is reserved and ignored here.
230
+ * @returns evaluator, or null when the expression is invalid.
231
+ */
232
+ export function compileMathExpr(expr: string, options: SafeMathOptions = {}): ((x: number) => number) | null {
233
+ try {
234
+ // `x` is the sampling variable and must never be shadowed by a param.
235
+ const vars: Record<string, number> = { ...options.vars }
236
+ delete vars.x
237
+ return new SafeMathParser(expr, vars).parse()
238
+ } catch {
239
+ return null
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Sample an expression over a range.
245
+ * @param expr - math expression in x, with optional parameters.
246
+ * @param xMin - inclusive start.
247
+ * @param xMax - inclusive end.
248
+ * @param samples - number of samples (>= 2).
249
+ * @param params - parameter values (e.g. { a: 2, b: 3 }); recompile per change.
250
+ * @returns [x, y] pairs; non-finite samples are dropped.
251
+ */
252
+ export function sampleExpr(
253
+ expr: string,
254
+ xMin: number,
255
+ xMax: number,
256
+ samples = 200,
257
+ params: Record<string, number> = {},
258
+ ): Array<[number, number]> {
259
+ const fn = compileMathExpr(expr, { vars: params })
260
+ if (fn === null) return []
261
+ if (!Number.isFinite(xMin) || !Number.isFinite(xMax) || xMax <= xMin || samples < 2) return []
262
+ const points: Array<[number, number]> = []
263
+ const step = (xMax - xMin) / (samples - 1)
264
+ for (let i = 0; i < samples; i++) {
265
+ const x = xMin + step * i
266
+ const y = fn(x)
267
+ if (Number.isFinite(y)) points.push([x, y])
268
+ }
269
+ return points
270
+ }