@seed-ship/mcp-ui-solid 6.21.0 → 6.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +173 -0
- package/README.md +102 -0
- package/dist/components/ChartJSRenderer.cjs +244 -68
- package/dist/components/ChartJSRenderer.cjs.map +1 -1
- package/dist/components/ChartJSRenderer.d.ts +9 -0
- package/dist/components/ChartJSRenderer.d.ts.map +1 -1
- package/dist/components/ChartJSRenderer.js +246 -70
- package/dist/components/ChartJSRenderer.js.map +1 -1
- package/dist/components/ExpandableWrapper.cjs +9 -1
- package/dist/components/ExpandableWrapper.cjs.map +1 -1
- package/dist/components/ExpandableWrapper.d.ts.map +1 -1
- package/dist/components/ExpandableWrapper.js +9 -1
- package/dist/components/ExpandableWrapper.js.map +1 -1
- package/dist/components/LightboxOverlay.cjs +139 -16
- package/dist/components/LightboxOverlay.cjs.map +1 -1
- package/dist/components/LightboxOverlay.d.ts +6 -0
- package/dist/components/LightboxOverlay.d.ts.map +1 -1
- package/dist/components/LightboxOverlay.js +141 -18
- package/dist/components/LightboxOverlay.js.map +1 -1
- package/dist/components/UIResourceRenderer.cjs +15 -5
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts +2 -1
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +15 -5
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/context/MCPUIConfigContext.cjs +2 -1
- package/dist/context/MCPUIConfigContext.cjs.map +1 -1
- package/dist/context/MCPUIConfigContext.d.ts +90 -12
- package/dist/context/MCPUIConfigContext.d.ts.map +1 -1
- package/dist/context/MCPUIConfigContext.js +2 -1
- package/dist/context/MCPUIConfigContext.js.map +1 -1
- package/dist/context/MCPUIMathContext.cjs +19 -0
- package/dist/context/MCPUIMathContext.cjs.map +1 -0
- package/dist/context/MCPUIMathContext.d.ts +18 -0
- package/dist/context/MCPUIMathContext.d.ts.map +1 -0
- package/dist/context/MCPUIMathContext.js +19 -0
- package/dist/context/MCPUIMathContext.js.map +1 -0
- package/dist/context/MCPUIStringsContext.cjs +6 -1
- package/dist/context/MCPUIStringsContext.cjs.map +1 -1
- package/dist/context/MCPUIStringsContext.d.ts +22 -0
- package/dist/context/MCPUIStringsContext.d.ts.map +1 -1
- package/dist/context/MCPUIStringsContext.js +6 -1
- package/dist/context/MCPUIStringsContext.js.map +1 -1
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks.cjs +2 -0
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.js +2 -0
- package/dist/hooks.js.map +1 -1
- package/dist/index.cjs +8 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/katex.cjs +23 -0
- package/dist/plugins/katex.cjs.map +1 -0
- package/dist/plugins/katex.d.cts +7 -0
- package/dist/plugins/katex.d.ts +7 -0
- package/dist/plugins/katex.d.ts.map +1 -0
- package/dist/plugins/katex.js +23 -0
- package/dist/plugins/katex.js.map +1 -0
- package/dist/utils/iframe-coep.cjs.map +1 -1
- package/dist/utils/iframe-coep.d.ts +8 -0
- package/dist/utils/iframe-coep.d.ts.map +1 -1
- package/dist/utils/iframe-coep.js.map +1 -1
- package/dist/utils/math-markdown.cjs +240 -0
- package/dist/utils/math-markdown.cjs.map +1 -0
- package/dist/utils/math-markdown.d.ts +16 -0
- package/dist/utils/math-markdown.d.ts.map +1 -0
- package/dist/utils/math-markdown.js +240 -0
- package/dist/utils/math-markdown.js.map +1 -0
- package/dist/utils/pan-zoom.cjs +312 -0
- package/dist/utils/pan-zoom.cjs.map +1 -0
- package/dist/utils/pan-zoom.d.ts +235 -0
- package/dist/utils/pan-zoom.d.ts.map +1 -0
- package/dist/utils/pan-zoom.js +312 -0
- package/dist/utils/pan-zoom.js.map +1 -0
- package/package.json +26 -3
- package/src/components/ChartJSRenderer.tsx +396 -1
- package/src/components/ExpandableWrapper.tsx +26 -0
- package/src/components/LightboxOverlay.tsx +280 -4
- package/src/components/UIResourceRenderer.tsx +24 -3
- package/src/context/MCPUIConfigContext.tsx +92 -12
- package/src/context/MCPUIMathContext.tsx +32 -0
- package/src/context/MCPUIStringsContext.tsx +30 -0
- package/src/hooks/index.ts +13 -0
- package/src/index.ts +20 -0
- package/src/plugins/katex.ts +25 -0
- package/src/utils/iframe-coep.ts +8 -0
- package/src/utils/math-markdown.ts +222 -0
- package/src/utils/pan-zoom.ts +779 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seed-ship/mcp-ui-solid",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.23.0",
|
|
4
4
|
"description": "SolidJS components for rendering MCP-generated UI resources",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -44,6 +44,9 @@
|
|
|
44
44
|
],
|
|
45
45
|
"plugins/duckdb": [
|
|
46
46
|
"./dist/plugins/duckdb.d.ts"
|
|
47
|
+
],
|
|
48
|
+
"plugins/katex": [
|
|
49
|
+
"./dist/plugins/katex.d.ts"
|
|
47
50
|
]
|
|
48
51
|
}
|
|
49
52
|
},
|
|
@@ -147,6 +150,17 @@
|
|
|
147
150
|
"default": "./dist/plugins/duckdb.cjs"
|
|
148
151
|
}
|
|
149
152
|
},
|
|
153
|
+
"./plugins/katex": {
|
|
154
|
+
"solid": "./src/plugins/katex.ts",
|
|
155
|
+
"import": {
|
|
156
|
+
"types": "./dist/plugins/katex.d.ts",
|
|
157
|
+
"default": "./dist/plugins/katex.js"
|
|
158
|
+
},
|
|
159
|
+
"require": {
|
|
160
|
+
"types": "./dist/plugins/katex.d.cts",
|
|
161
|
+
"default": "./dist/plugins/katex.cjs"
|
|
162
|
+
}
|
|
163
|
+
},
|
|
150
164
|
"./src/*": "./src/*"
|
|
151
165
|
},
|
|
152
166
|
"_size-limit-note": "size-limit is a regression guardrail, not a hard cap — it flags accidental bundle bloat. It measures the pre-built dist/index.js and sums the FULL graph reachable from each entry, including lazy import() chunks (leaflet, @antv/g6, chart.js) a consumer fetches on demand, NOT at import time — so the figures are worst-case totals, not eager load cost. Since v6.6.0 the 'Streaming renderer' entry delegates to UIResourceRenderer (Gap 1 parity), so its graph equals the full renderer set; its budget is set to 1 MB (generous headroom — the real eager cost is far lower). The 'Hooks only' and 'Full bundle' limits are pre-existing and unchanged by v6.6.0. size-limit is informational only — it does not gate CI.",
|
|
@@ -174,7 +188,9 @@
|
|
|
174
188
|
"@duckdb/duckdb-wasm": "^1.28.0",
|
|
175
189
|
"@tanstack/solid-virtual": "^3.0.0",
|
|
176
190
|
"chart.js": "^4.0.0",
|
|
191
|
+
"chartjs-plugin-zoom": "^2.0.0",
|
|
177
192
|
"highlight.js": "^11.9.0",
|
|
193
|
+
"katex": "0.18.4",
|
|
178
194
|
"leaflet": "^1.9.4",
|
|
179
195
|
"leaflet.markercluster": "^1.5.0",
|
|
180
196
|
"protomaps-leaflet": "^4.0.0",
|
|
@@ -190,9 +206,15 @@
|
|
|
190
206
|
"chart.js": {
|
|
191
207
|
"optional": true
|
|
192
208
|
},
|
|
209
|
+
"chartjs-plugin-zoom": {
|
|
210
|
+
"optional": true
|
|
211
|
+
},
|
|
193
212
|
"highlight.js": {
|
|
194
213
|
"optional": true
|
|
195
214
|
},
|
|
215
|
+
"katex": {
|
|
216
|
+
"optional": true
|
|
217
|
+
},
|
|
196
218
|
"leaflet": {
|
|
197
219
|
"optional": true
|
|
198
220
|
},
|
|
@@ -228,6 +250,7 @@
|
|
|
228
250
|
"chart.js": "^4.5.1",
|
|
229
251
|
"eslint": "^8.56.0",
|
|
230
252
|
"jsdom": "^27.2.0",
|
|
253
|
+
"katex": "0.18.4",
|
|
231
254
|
"leaflet.markercluster": "^1.5.3",
|
|
232
255
|
"size-limit": "^12.0.0",
|
|
233
256
|
"solid-js": "^1.9.11",
|
|
@@ -258,7 +281,7 @@
|
|
|
258
281
|
},
|
|
259
282
|
"scripts": {
|
|
260
283
|
"build": "vite build && pnpm build:types",
|
|
261
|
-
"build:types": "tsc --declaration --emitDeclarationOnly --outDir dist --composite false && pnpm build:types:copy",
|
|
284
|
+
"build:types": "tsc --declaration --emitDeclarationOnly --outDir dist --composite false && pnpm build:types:copy && cp dist/plugins/katex.d.ts dist/plugins/katex.d.cts",
|
|
262
285
|
"build:types:copy": "printf \"export * from './components/index';\\nexport { default } from './components/index';\\n\" > dist/components.d.ts && printf \"export * from './hooks/index';\\n\" > dist/hooks.d.ts && printf \"export * from './types/index';\\n\" > dist/types.d.ts && printf \"export * from './adapters/index';\\n\" > dist/adapters.d.ts && pnpm build:types:cts",
|
|
263
286
|
"build:types:cts": "cp dist/index.d.ts dist/index.d.cts && cp dist/components.d.ts dist/components.d.cts 2>/dev/null || true && cp dist/hooks.d.ts dist/hooks.d.cts 2>/dev/null || true && cp dist/types.d.ts dist/types.d.cts 2>/dev/null || true && cp dist/validation.d.ts dist/validation.d.cts 2>/dev/null || true && cp dist/types-export.d.ts dist/types-export.d.cts 2>/dev/null || true && cp dist/adapters.d.ts dist/adapters.d.cts 2>/dev/null || true && cp dist/adapters/presentation.d.ts dist/adapters/presentation.d.cts 2>/dev/null || true && cp dist/plugins/duckdb.d.ts dist/plugins/duckdb.d.cts 2>/dev/null || true",
|
|
264
287
|
"dev": "vite build --watch",
|
|
@@ -266,7 +289,7 @@
|
|
|
266
289
|
"test:watch": "vitest",
|
|
267
290
|
"test:browser": "vitest --mode browser",
|
|
268
291
|
"lint": "eslint src",
|
|
269
|
-
"typecheck": "tsc --noEmit",
|
|
292
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.contracts.json",
|
|
270
293
|
"clean": "rm -rf dist",
|
|
271
294
|
"size": "size-limit",
|
|
272
295
|
"size:analyze": "size-limit --why"
|
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
* ```
|
|
7
7
|
* pnpm add chart.js
|
|
8
8
|
* ```
|
|
9
|
+
*
|
|
10
|
+
* Wheel-zoom / drag-pan / pinch (v6.22.0) need a SECOND optional peer, since a
|
|
11
|
+
* canvas cannot be zoomed with a CSS transform without blurring:
|
|
12
|
+
* ```
|
|
13
|
+
* pnpm add chartjs-plugin-zoom
|
|
14
|
+
* ```
|
|
15
|
+
* Without it the chart renders exactly as it did before 6.22.0. Where zoom is
|
|
16
|
+
* offered is the host's call — see `MCPUIConfig.chartZoom`, default
|
|
17
|
+
* `'expanded'` (fullscreen modal only).
|
|
9
18
|
*/
|
|
10
19
|
|
|
11
20
|
import {
|
|
@@ -16,6 +25,7 @@ import {
|
|
|
16
25
|
createUniqueId,
|
|
17
26
|
onCleanup,
|
|
18
27
|
createSignal,
|
|
28
|
+
untrack,
|
|
19
29
|
Show,
|
|
20
30
|
} from 'solid-js';
|
|
21
31
|
import type { Accessor, JSX } from 'solid-js';
|
|
@@ -24,6 +34,7 @@ import { ExpandableWrapper, useExpanded } from './ExpandableWrapper';
|
|
|
24
34
|
import { DegradedFallback } from './DegradedFallback';
|
|
25
35
|
import { chartToDataTable } from './chart-data-table';
|
|
26
36
|
import { useTelemetry } from '../context/MCPUITelemetryContext';
|
|
37
|
+
import { useMCPUIConfig } from '../context/MCPUIConfigContext';
|
|
27
38
|
import { formatMCPUIString, useMCPUIStrings } from '../context/MCPUIStringsContext';
|
|
28
39
|
|
|
29
40
|
// Lazy load Chart.js to avoid bundling if not used
|
|
@@ -60,6 +71,184 @@ export async function isChartJSAvailable(): Promise<boolean> {
|
|
|
60
71
|
}
|
|
61
72
|
}
|
|
62
73
|
|
|
74
|
+
// ─── Optional zoom peer (v6.22.0) ─────────────────────────────────────────
|
|
75
|
+
//
|
|
76
|
+
// A chart is a CANVAS, so the CSS-transform approach used by `createPanZoom`
|
|
77
|
+
// for DOM surfaces would just magnify a bitmap into a blurry one. Real chart
|
|
78
|
+
// zoom means re-projecting the scales, which only Chart.js itself can do —
|
|
79
|
+
// hence `chartjs-plugin-zoom`, wired here as an OPTIONAL peer dependency
|
|
80
|
+
// alongside `chart.js`, `leaflet` and `@antv/g6`. A host that does not install
|
|
81
|
+
// it gets exactly the chart this renderer built before 6.22.0.
|
|
82
|
+
|
|
83
|
+
let zoomPluginPromise: Promise<unknown | null> | null = null;
|
|
84
|
+
let zoomPluginRegistered = false;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Import `chartjs-plugin-zoom` once, lazily.
|
|
88
|
+
*
|
|
89
|
+
* Resolves to `null` instead of rejecting when the peer is absent: an optional
|
|
90
|
+
* dependency that is simply not installed is not an error condition, and the
|
|
91
|
+
* caller must be able to fall through to the unzoomable chart without an error
|
|
92
|
+
* state or console noise. The `null` is cached with the promise — a peer that
|
|
93
|
+
* is missing now is still missing on the next chart, and retrying the import
|
|
94
|
+
* per render would re-log the module-resolution failure every time.
|
|
95
|
+
*
|
|
96
|
+
* Only ever called from an effect, never at module scope or during render, so
|
|
97
|
+
* SSR never reaches `hammerjs` (a plugin dependency that reads `document` as
|
|
98
|
+
* it loads).
|
|
99
|
+
*
|
|
100
|
+
* `@vite-ignore` for the same reason `duckdb.ts` and `MapRenderer.tsx` carry
|
|
101
|
+
* it on THEIR optional peers: this library is built with the peer externalised,
|
|
102
|
+
* so the bare specifier survives into the published bundle and a consumer's
|
|
103
|
+
* bundler resolves it statically — an app that never installed the plugin then
|
|
104
|
+
* fails its own dependency scan at build time, long before the `.catch()`
|
|
105
|
+
* below could turn the absence into the supported `null`. The four peers that
|
|
106
|
+
* anyone using the feature at all has installed (`chart.js`, `leaflet`,
|
|
107
|
+
* `@antv/g6`, `highlight.js`) stay bare on purpose: they benefit from the
|
|
108
|
+
* pre-bundling this comment opts out of.
|
|
109
|
+
*/
|
|
110
|
+
const loadZoomPlugin = (): Promise<unknown | null> => {
|
|
111
|
+
if (!zoomPluginPromise) {
|
|
112
|
+
zoomPluginPromise = import(/* @vite-ignore */ 'chartjs-plugin-zoom')
|
|
113
|
+
.then((module: any) => module.default ?? module)
|
|
114
|
+
.catch(() => null);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return zoomPluginPromise;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Load the plugin and register it on the Chart.js singleton, once per process.
|
|
122
|
+
*
|
|
123
|
+
* Registration is global and not undoable, which is precisely why it is
|
|
124
|
+
* deferred to the first surface that actually asks for zoom
|
|
125
|
+
* (`MCPUIConfig.chartZoom`): with the default `'expanded'`, a chart sitting
|
|
126
|
+
* inline in a chat feed never even downloads the plugin.
|
|
127
|
+
*
|
|
128
|
+
* Chart.js only gives the plugin's hooks to charts constructed AFTER the
|
|
129
|
+
* `register()` call, so the caller rebuilds its chart once this resolves.
|
|
130
|
+
*
|
|
131
|
+
* Resolves to `null` on EVERY failure and never rejects. Its one call site is
|
|
132
|
+
* a fire-and-forget `.then()` inside an effect, with nowhere to put a rejection
|
|
133
|
+
* handler that would mean anything to the host: `loadZoomPlugin()` already
|
|
134
|
+
* swallows the missing optional peer, but `loadChartJS()` below rejects on a
|
|
135
|
+
* host that installed neither, and that rejection would surface as an
|
|
136
|
+
* unhandled one. The chart's own render effect reports the missing `chart.js`
|
|
137
|
+
* through `onError` and the degraded table; zoom just quietly is not offered.
|
|
138
|
+
*/
|
|
139
|
+
const ensureZoomPlugin = async (): Promise<unknown | null> => {
|
|
140
|
+
try {
|
|
141
|
+
const plugin = await loadZoomPlugin();
|
|
142
|
+
if (!plugin) return null;
|
|
143
|
+
|
|
144
|
+
if (!zoomPluginRegistered) {
|
|
145
|
+
const Chart: any = await loadChartJS();
|
|
146
|
+
// A stubbed or partial Chart build may not expose `register`; treat that
|
|
147
|
+
// like an absent peer. Returning the plugin here would be worse than
|
|
148
|
+
// returning nothing: the renderer would hang zoom options off a chart
|
|
149
|
+
// that has no zoom hooks, and paint a toolbar whose buttons do nothing.
|
|
150
|
+
if (typeof Chart?.register !== 'function') return null;
|
|
151
|
+
|
|
152
|
+
Chart.register(plugin);
|
|
153
|
+
// Set only now that a registration has actually happened, so a later
|
|
154
|
+
// call still gets its chance on a Chart build that does expose it.
|
|
155
|
+
zoomPluginRegistered = true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return plugin;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Chart types whose axes zoom meaningfully.
|
|
166
|
+
*
|
|
167
|
+
* `pie`, `doughnut`, `polarArea` and `radar` carry no cartesian scales, so the
|
|
168
|
+
* plugin would capture the wheel and move nothing — strictly worse than not
|
|
169
|
+
* offering zoom at all.
|
|
170
|
+
*/
|
|
171
|
+
const ZOOMABLE_CHART_TYPES = new Set(['bar', 'line', 'scatter', 'bubble']);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Ratio one press of the chart's zoom buttons applies.
|
|
175
|
+
*
|
|
176
|
+
* The same 1.5 the lightbox uses for `+` / `-`: coarse enough that a couple of
|
|
177
|
+
* presses get somewhere, fine enough to stop on a region of interest.
|
|
178
|
+
*/
|
|
179
|
+
const CHART_ZOOM_BUTTON_FACTOR = 1.5;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Whether a chart instance currently sits off its initial viewport.
|
|
183
|
+
*
|
|
184
|
+
* `isZoomedOrPanned()` is the plugin's own API (2.x). The `getZoomLevel()`
|
|
185
|
+
* branch covers an older plugin build that only exposes the zoom factor, and
|
|
186
|
+
* the `false` covers a chart created before the plugin was registered.
|
|
187
|
+
*/
|
|
188
|
+
const isChartZoomed = (chart: any): boolean => {
|
|
189
|
+
if (typeof chart?.isZoomedOrPanned === 'function') return Boolean(chart.isZoomedOrPanned());
|
|
190
|
+
const level = chart?.getZoomLevel?.();
|
|
191
|
+
return typeof level === 'number' ? level !== 1 : false;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** A JSON-ish object — the only shape a zoom option tree is ever built from. */
|
|
195
|
+
const isPlainZoomObject = (value: unknown): value is Record<string, unknown> =>
|
|
196
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Deep-merge this renderer's zoom block with the consumer's own.
|
|
200
|
+
*
|
|
201
|
+
* `ChartComponentParams.options` is the public Chart.js escape hatch, and
|
|
202
|
+
* `plugins.zoom` is where a host tunes what this renderer cannot guess:
|
|
203
|
+
* `limits`, wheel `speed`, a pan `modifierKey`, its own callbacks. Spreading
|
|
204
|
+
* the renderer's block over it would silently drop the lot, so the two trees
|
|
205
|
+
* are merged branch by branch (`zoom.wheel`, `zoom.pinch`, `zoom.drag`, `pan`,
|
|
206
|
+
* `limits`, …).
|
|
207
|
+
*
|
|
208
|
+
* Two rules, both deliberate:
|
|
209
|
+
* - on a plain value the CALLER wins — an explicit `mode: 'x'` is a more
|
|
210
|
+
* specific instruction than this renderer's default `'xy'`;
|
|
211
|
+
* - on a function both run, renderer first. The only functions the renderer
|
|
212
|
+
* puts in that tree are `onZoom` / `onPan`, and they are what keeps the
|
|
213
|
+
* reset control in sync with the viewport — letting a caller's callback
|
|
214
|
+
* REPLACE them would leave the toolbar lying about the chart, so they are
|
|
215
|
+
* composed instead.
|
|
216
|
+
*/
|
|
217
|
+
const mergeZoomOptions = (
|
|
218
|
+
base: Record<string, unknown>,
|
|
219
|
+
override: unknown
|
|
220
|
+
): Record<string, unknown> => {
|
|
221
|
+
if (!isPlainZoomObject(override)) return base;
|
|
222
|
+
|
|
223
|
+
const merged: Record<string, unknown> = { ...base };
|
|
224
|
+
|
|
225
|
+
for (const [key, value] of Object.entries(override)) {
|
|
226
|
+
// An explicitly-undefined key is an OMISSION, not an instruction to erase.
|
|
227
|
+
// A host forwarding an optional prop — `zoom: { onZoom: props.onZoom }`
|
|
228
|
+
// with nothing passed — would otherwise delete the renderer's own hook,
|
|
229
|
+
// which is exactly what keeps the reset control honest. This is also the
|
|
230
|
+
// semantics `mergeProps` gives every other merge in this package.
|
|
231
|
+
if (value === undefined) continue;
|
|
232
|
+
|
|
233
|
+
const ours = merged[key];
|
|
234
|
+
|
|
235
|
+
if (isPlainZoomObject(ours) && isPlainZoomObject(value)) {
|
|
236
|
+
merged[key] = mergeZoomOptions(ours, value);
|
|
237
|
+
} else if (typeof ours === 'function' && typeof value === 'function') {
|
|
238
|
+
const rendererHook = ours as (...args: unknown[]) => unknown;
|
|
239
|
+
const callerHook = value as (...args: unknown[]) => unknown;
|
|
240
|
+
merged[key] = (...args: unknown[]) => {
|
|
241
|
+
rendererHook(...args);
|
|
242
|
+
return callerHook(...args);
|
|
243
|
+
};
|
|
244
|
+
} else {
|
|
245
|
+
merged[key] = value;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return merged;
|
|
250
|
+
};
|
|
251
|
+
|
|
63
252
|
export interface ChartJSRendererProps {
|
|
64
253
|
/**
|
|
65
254
|
* UIComponent with chart params
|
|
@@ -108,12 +297,20 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
|
|
|
108
297
|
const [isLoading, setIsLoading] = createSignal(true);
|
|
109
298
|
const [error, setError] = createSignal<string>();
|
|
110
299
|
const [activeView, setActiveView] = createSignal<'chart' | 'data'>('chart');
|
|
300
|
+
// Reported by `<ExpandableWrapper onExpandedChange>` rather than read from
|
|
301
|
+
// `useExpanded()`: this component IS the wrapper's owner, so its own
|
|
302
|
+
// `useExpanded()` call sits above the wrapper's provider and would only ever
|
|
303
|
+
// see an outer wrapper (see ExpandableWrapperProps.onExpandedChange).
|
|
304
|
+
const [isExpanded, setIsExpanded] = createSignal(false);
|
|
305
|
+
const [zoomPlugin, setZoomPlugin] = createSignal<unknown | null>(null);
|
|
306
|
+
const [isZoomed, setIsZoomed] = createSignal(false);
|
|
111
307
|
let canvasRef: HTMLCanvasElement | undefined;
|
|
112
308
|
let chartInstance: any;
|
|
113
309
|
let renderVersion = 0;
|
|
114
310
|
|
|
115
311
|
const params = () => props.component.params as ChartComponentParams;
|
|
116
312
|
const telemetry = useTelemetry();
|
|
313
|
+
const config = useMCPUIConfig();
|
|
117
314
|
const strings = useMCPUIStrings();
|
|
118
315
|
// The accessible data table is a pure projection: its column headers come
|
|
119
316
|
// from the chrome strings, injected rather than hardcoded in the helper.
|
|
@@ -137,6 +334,107 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
|
|
|
137
334
|
// Lazy-stringified each time the button is clicked.
|
|
138
335
|
const copyDataJSON = () => JSON.stringify({ type: params().type, data: params().data }, null, 2);
|
|
139
336
|
|
|
337
|
+
// ── Zoom / pan (v6.22.0) ───────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Whether this chart should be capturing the wheel right now: the host
|
|
341
|
+
* policy (`MCPUIConfig.chartZoom`) crossed with the surface the chart
|
|
342
|
+
* currently lives on, and with the chart type.
|
|
343
|
+
*
|
|
344
|
+
* `'expanded'` (the default) only says yes inside `ExpandableWrapper`'s
|
|
345
|
+
* fullscreen modal — the one surface that owns its viewport. Inline, the
|
|
346
|
+
* wheel belongs to the page the chart is scrolling in.
|
|
347
|
+
*/
|
|
348
|
+
const zoomWanted = createMemo(() => {
|
|
349
|
+
if (!ZOOMABLE_CHART_TYPES.has(params().type)) return false;
|
|
350
|
+
// `false` (or `null`) on the host's own `plugins.zoom` is Chart.js's
|
|
351
|
+
// documented per-chart plugin opt-out. It speaks about THIS chart, so it
|
|
352
|
+
// beats the host-wide `chartZoom` policy — and gating here rather than at
|
|
353
|
+
// the options merge keeps one source of truth, so the plugin is never even
|
|
354
|
+
// downloaded and the toolbar never offers controls that would do nothing.
|
|
355
|
+
const hostZoom = params().options?.plugins?.zoom;
|
|
356
|
+
if (hostZoom === false || hostZoom === null) return false;
|
|
357
|
+
const mode = config.chartZoom;
|
|
358
|
+
if (mode === 'never') return false;
|
|
359
|
+
if (mode === 'always') return true;
|
|
360
|
+
return isExpanded();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Download + register the plugin the first time a surface asks for zoom.
|
|
364
|
+
// `zoomPlugin` is read untracked: setting it must not re-run this effect.
|
|
365
|
+
createEffect(() => {
|
|
366
|
+
if (!zoomWanted() || untrack(zoomPlugin)) return;
|
|
367
|
+
void ensureZoomPlugin().then((plugin) => {
|
|
368
|
+
if (plugin) setZoomPlugin(() => plugin);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The plugin's Chart.js options. Built once per renderer instance so that
|
|
374
|
+
* `zoomOptions()` keeps a STABLE identity: a fresh object each read would
|
|
375
|
+
* look like a config change and rebuild the chart on every update.
|
|
376
|
+
*/
|
|
377
|
+
const zoomPluginOptions = {
|
|
378
|
+
zoom: {
|
|
379
|
+
wheel: { enabled: true },
|
|
380
|
+
pinch: { enabled: true },
|
|
381
|
+
// Drag is reserved for panning below; a drag-to-select zoom would make
|
|
382
|
+
// the two gestures fight over the same mouse button.
|
|
383
|
+
drag: { enabled: false },
|
|
384
|
+
mode: 'xy',
|
|
385
|
+
onZoom: ({ chart }: { chart: unknown }) => setIsZoomed(isChartZoomed(chart)),
|
|
386
|
+
},
|
|
387
|
+
pan: {
|
|
388
|
+
enabled: true,
|
|
389
|
+
mode: 'xy',
|
|
390
|
+
onPan: ({ chart }: { chart: unknown }) => setIsZoomed(isChartZoomed(chart)),
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The `plugins.zoom` block to merge into the chart config, or `null`.
|
|
396
|
+
*
|
|
397
|
+
* `null` while the peer is absent, which keeps the built config byte-for-byte
|
|
398
|
+
* what it was before 6.22.0 — and, because the memo then never changes value,
|
|
399
|
+
* also keeps expanding the modal from rebuilding the chart at all on a host
|
|
400
|
+
* that did not install the plugin.
|
|
401
|
+
*/
|
|
402
|
+
const zoomOptions = createMemo(() => (zoomWanted() && zoomPlugin() ? zoomPluginOptions : null));
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Whether the zoom toolbar belongs on screen at all: the chart is zoomable
|
|
406
|
+
* here (host policy × surface × chart type, and the peer actually loaded),
|
|
407
|
+
* and the chart is what is currently being shown.
|
|
408
|
+
*/
|
|
409
|
+
const zoomControlsShown = () =>
|
|
410
|
+
Boolean(zoomOptions()) && activeView() === 'chart' && !error();
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Zoom the chart by a fixed ratio, about its centre.
|
|
414
|
+
*
|
|
415
|
+
* Every gesture the plugin offers — wheel, drag-pan, pinch — is a POINTER
|
|
416
|
+
* gesture, and the canvas is not focusable, so without these buttons the
|
|
417
|
+
* whole feature is unreachable by keyboard, switch or voice (WCAG 2.1.1) —
|
|
418
|
+
* including the reset control, which only exists once a gesture has already
|
|
419
|
+
* moved the chart. The lightbox settled the same question the same way, with
|
|
420
|
+
* persistent `+` / `-` controls next to its pointer gestures.
|
|
421
|
+
*
|
|
422
|
+
* `chart.zoom()` is `chartjs-plugin-zoom`'s own imperative API; the `?.`
|
|
423
|
+
* covers a chart built before the plugin was registered. The plugin calls
|
|
424
|
+
* `onZoom` itself, but the state is written here too so the reset control
|
|
425
|
+
* appears even on a build whose hook does not fire for a programmatic zoom.
|
|
426
|
+
*/
|
|
427
|
+
const handleZoomBy = (factor: number) => {
|
|
428
|
+
chartInstance?.zoom?.(factor);
|
|
429
|
+
setIsZoomed(isChartZoomed(chartInstance));
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
/** Back to the chart's initial viewport. */
|
|
433
|
+
const handleResetZoom = () => {
|
|
434
|
+
chartInstance?.resetZoom?.();
|
|
435
|
+
setIsZoomed(false);
|
|
436
|
+
};
|
|
437
|
+
|
|
140
438
|
// Chart PNG export
|
|
141
439
|
const handleExportPNG = () => {
|
|
142
440
|
if (!canvasRef) return;
|
|
@@ -151,8 +449,14 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
|
|
|
151
449
|
createEffect(async () => {
|
|
152
450
|
if (!canvasRef) return;
|
|
153
451
|
|
|
154
|
-
// Access params to track dependencies
|
|
452
|
+
// Access params to track dependencies. `zoomOptions()` is read here, before
|
|
453
|
+
// the first `await`, for the same reason: an async effect only tracks what
|
|
454
|
+
// it reads synchronously, and toggling the expanded state has to rebuild
|
|
455
|
+
// the chart rather than leave it on a stale config (Chart.js reads the
|
|
456
|
+
// plugin options at construction and the plugin only hooks charts created
|
|
457
|
+
// after it was registered).
|
|
155
458
|
const chartParams = params();
|
|
459
|
+
const zoom = zoomOptions();
|
|
156
460
|
const version = ++renderVersion;
|
|
157
461
|
|
|
158
462
|
setIsLoading(true);
|
|
@@ -171,6 +475,7 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
|
|
|
171
475
|
}
|
|
172
476
|
|
|
173
477
|
// Build options, merging time-axis config if present (v3.1.0)
|
|
478
|
+
const hostZoomOptions = chartParams.options?.plugins?.zoom;
|
|
174
479
|
const baseOptions: any = {
|
|
175
480
|
responsive: true,
|
|
176
481
|
maintainAspectRatio: false,
|
|
@@ -182,6 +487,14 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
|
|
|
182
487
|
position: 'bottom',
|
|
183
488
|
...chartParams.options?.plugins?.legend,
|
|
184
489
|
},
|
|
490
|
+
// Present only once the optional peer has loaded AND the current
|
|
491
|
+
// surface wants zoom. Merged rather than assigned, so a consumer's
|
|
492
|
+
// own `plugins.zoom` survives; when zoom is off, the spread above
|
|
493
|
+
// has already left their block exactly as they wrote it.
|
|
494
|
+
// An explicit `plugins.zoom: false` never reaches here: `zoomWanted`
|
|
495
|
+
// already returned false for it, so `zoom` is null and the spread
|
|
496
|
+
// above carries the host's own value through untouched.
|
|
497
|
+
...(zoom ? { zoom: mergeZoomOptions(zoom, hostZoomOptions) } : {}),
|
|
185
498
|
},
|
|
186
499
|
};
|
|
187
500
|
|
|
@@ -211,6 +524,9 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
|
|
|
211
524
|
options: baseOptions,
|
|
212
525
|
});
|
|
213
526
|
|
|
527
|
+
// A freshly built chart always sits on its initial viewport, so the
|
|
528
|
+
// reset control goes away until the user zooms again.
|
|
529
|
+
setIsZoomed(false);
|
|
214
530
|
setIsLoading(false);
|
|
215
531
|
} catch (err) {
|
|
216
532
|
if (version !== renderVersion) return;
|
|
@@ -252,6 +568,7 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
|
|
|
252
568
|
copyData={copyDataJSON()}
|
|
253
569
|
copyLabel={strings.chartCopy}
|
|
254
570
|
toolbarVariant={props.toolbarVariant}
|
|
571
|
+
onExpandedChange={setIsExpanded}
|
|
255
572
|
>
|
|
256
573
|
<ContextAwareChartLayout
|
|
257
574
|
render={(isExpanded) => (
|
|
@@ -297,6 +614,84 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
|
|
|
297
614
|
{strings.chartDataView}
|
|
298
615
|
</button>
|
|
299
616
|
</div>
|
|
617
|
+
{/* Zoom controls — permanently visible (not hover-revealed like
|
|
618
|
+
the export button) for as long as this chart is zoomable at
|
|
619
|
+
all. Zoom in/out come FIRST and unconditionally: they are the
|
|
620
|
+
only keyboard route into a feature whose gestures are all
|
|
621
|
+
pointer gestures, on a canvas that takes no focus. Reset joins
|
|
622
|
+
them only once the chart is actually off its initial viewport,
|
|
623
|
+
so the toolbar stays quiet on a chart nobody moved. */}
|
|
624
|
+
<Show when={zoomControlsShown()}>
|
|
625
|
+
<div class="inline-flex items-center gap-1">
|
|
626
|
+
<button
|
|
627
|
+
type="button"
|
|
628
|
+
onClick={() => handleZoomBy(1 / CHART_ZOOM_BUTTON_FACTOR)}
|
|
629
|
+
class="opacity-60 hover:opacity-100 px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all shadow-sm"
|
|
630
|
+
title={strings.zoomOut}
|
|
631
|
+
aria-label={strings.zoomOut}
|
|
632
|
+
>
|
|
633
|
+
<svg
|
|
634
|
+
class="w-3 h-3 text-gray-500 dark:text-gray-400"
|
|
635
|
+
fill="none"
|
|
636
|
+
viewBox="0 0 24 24"
|
|
637
|
+
stroke="currentColor"
|
|
638
|
+
>
|
|
639
|
+
<path
|
|
640
|
+
stroke-linecap="round"
|
|
641
|
+
stroke-linejoin="round"
|
|
642
|
+
stroke-width="2"
|
|
643
|
+
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607zM13.5 10.5h-6"
|
|
644
|
+
/>
|
|
645
|
+
</svg>
|
|
646
|
+
</button>
|
|
647
|
+
|
|
648
|
+
<button
|
|
649
|
+
type="button"
|
|
650
|
+
onClick={() => handleZoomBy(CHART_ZOOM_BUTTON_FACTOR)}
|
|
651
|
+
class="opacity-60 hover:opacity-100 px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all shadow-sm"
|
|
652
|
+
title={strings.zoomIn}
|
|
653
|
+
aria-label={strings.zoomIn}
|
|
654
|
+
>
|
|
655
|
+
<svg
|
|
656
|
+
class="w-3 h-3 text-gray-500 dark:text-gray-400"
|
|
657
|
+
fill="none"
|
|
658
|
+
viewBox="0 0 24 24"
|
|
659
|
+
stroke="currentColor"
|
|
660
|
+
>
|
|
661
|
+
<path
|
|
662
|
+
stroke-linecap="round"
|
|
663
|
+
stroke-linejoin="round"
|
|
664
|
+
stroke-width="2"
|
|
665
|
+
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607zM10.5 7.5v6m3-3h-6"
|
|
666
|
+
/>
|
|
667
|
+
</svg>
|
|
668
|
+
</button>
|
|
669
|
+
|
|
670
|
+
<Show when={isZoomed()}>
|
|
671
|
+
<button
|
|
672
|
+
type="button"
|
|
673
|
+
onClick={handleResetZoom}
|
|
674
|
+
class="opacity-60 hover:opacity-100 px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all shadow-sm"
|
|
675
|
+
title={strings.zoomReset}
|
|
676
|
+
aria-label={strings.zoomReset}
|
|
677
|
+
>
|
|
678
|
+
<svg
|
|
679
|
+
class="w-3 h-3 text-gray-500 dark:text-gray-400"
|
|
680
|
+
fill="none"
|
|
681
|
+
viewBox="0 0 24 24"
|
|
682
|
+
stroke="currentColor"
|
|
683
|
+
>
|
|
684
|
+
<path
|
|
685
|
+
stroke-linecap="round"
|
|
686
|
+
stroke-linejoin="round"
|
|
687
|
+
stroke-width="2"
|
|
688
|
+
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
689
|
+
/>
|
|
690
|
+
</svg>
|
|
691
|
+
</button>
|
|
692
|
+
</Show>
|
|
693
|
+
</div>
|
|
694
|
+
</Show>
|
|
300
695
|
<Show when={exportEnabled()}>
|
|
301
696
|
<button
|
|
302
697
|
type="button"
|
|
@@ -113,7 +113,32 @@ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
|
|
|
113
113
|
}
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Did the gesture that produced the current click START on the backdrop?
|
|
118
|
+
*
|
|
119
|
+
* `null` means no pointer press was seen — a synthetic or programmatic
|
|
120
|
+
* click, which stays allowed so keyboard and test-driven closes keep working.
|
|
121
|
+
*/
|
|
122
|
+
let pressedOnBackdrop: boolean | null = null
|
|
123
|
+
|
|
124
|
+
const handleBackdropPointerDown = (e: PointerEvent) => {
|
|
125
|
+
pressedOnBackdrop = e.target === e.currentTarget
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Closing on a backdrop click is only safe when the gesture also BEGAN there.
|
|
130
|
+
*
|
|
131
|
+
* When a press and its release have different targets the browser dispatches
|
|
132
|
+
* `click` on their nearest common ancestor — here the backdrop. So a drag
|
|
133
|
+
* that starts on the content and releases over the 16px margin around the
|
|
134
|
+
* panel (panning a zoomed chart, selecting text in a table) would otherwise
|
|
135
|
+
* land on this handler with `e.target === e.currentTarget` and tear the modal
|
|
136
|
+
* down mid-gesture.
|
|
137
|
+
*/
|
|
116
138
|
const handleBackdropClick = (e: MouseEvent) => {
|
|
139
|
+
const startedOnBackdrop = pressedOnBackdrop
|
|
140
|
+
pressedOnBackdrop = null
|
|
141
|
+
if (startedOnBackdrop === false) return
|
|
117
142
|
if (e.target === e.currentTarget) handleClose()
|
|
118
143
|
}
|
|
119
144
|
|
|
@@ -164,6 +189,7 @@ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
|
|
|
164
189
|
class="fixed inset-0 z-50 flex flex-col bg-black/50 backdrop-blur-sm"
|
|
165
190
|
style={{ animation: 'expandable-fade-in 0.15s ease-out' }}
|
|
166
191
|
onClick={handleBackdropClick}
|
|
192
|
+
onPointerDown={handleBackdropPointerDown}
|
|
167
193
|
role="dialog"
|
|
168
194
|
data-mcp-ui-portal="dialog"
|
|
169
195
|
aria-modal="true"
|