pmx-canvas 0.1.8 → 0.1.10

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.
@@ -15,9 +15,31 @@ import { catalog } from '../catalog';
15
15
  import { chartComponents } from '../charts/components';
16
16
  import { extraChartComponents } from '../charts/extra-components';
17
17
 
18
+ type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'info' | 'warning' | 'error' | 'danger';
19
+ type BadgeProps = {
20
+ text: string;
21
+ variant?: BadgeVariant | null;
22
+ className?: string | null;
23
+ };
24
+
25
+ function Badge({ props }: { props: BadgeProps }) {
26
+ const variant = props.variant;
27
+ const resolvedVariant = variant ?? 'default';
28
+ return (
29
+ <span
30
+ data-slot="badge"
31
+ data-variant={resolvedVariant}
32
+ className={`pmx-badge pmx-badge--${resolvedVariant}`}
33
+ >
34
+ {props.text}
35
+ </span>
36
+ );
37
+ }
38
+
18
39
  const { registry } = defineRegistry(catalog as never, {
19
40
  components: {
20
41
  ...shadcnComponents,
42
+ Badge,
21
43
  ...chartComponents,
22
44
  ...extraChartComponents,
23
45
  } as never,
@@ -262,14 +262,6 @@ function normalizeButtonVariant(value: unknown): unknown {
262
262
  return value;
263
263
  }
264
264
 
265
- function normalizeBadgeVariant(value: unknown): unknown {
266
- if (value === 'success') return 'default';
267
- if (value === 'info') return 'secondary';
268
- if (value === 'warning') return 'outline';
269
- if (value === 'error' || value === 'danger') return 'destructive';
270
- return value;
271
- }
272
-
273
265
  function deriveElementName(elementKey: string): string {
274
266
  const normalized = elementKey.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
275
267
  return normalized || 'field';
@@ -344,9 +336,6 @@ function normalizeElementProps(
344
336
  if ('label' in props) {
345
337
  delete props.label;
346
338
  }
347
- if ('variant' in props) {
348
- props.variant = normalizeBadgeVariant(props.variant);
349
- }
350
339
  }
351
340
 
352
341
  if (type === 'Select' || type === 'Radio') {
@@ -430,6 +419,7 @@ function normalizeSpec(spec: Record<string, unknown>): Record<string, unknown> {
430
419
  const elementChanged =
431
420
  resolvedType !== element.type ||
432
421
  JSON.stringify(normalizedProps) !== JSON.stringify(rawProps) ||
422
+ !('visible' in element) ||
433
423
  !Array.isArray(element.children) ||
434
424
  normalizedChildren.length !== element.children.length;
435
425
 
@@ -438,6 +428,7 @@ function normalizeSpec(spec: Record<string, unknown>): Record<string, unknown> {
438
428
  ...element,
439
429
  type: resolvedType,
440
430
  props: normalizedProps,
431
+ visible: 'visible' in element ? element.visible : true,
441
432
  children: normalizedChildren,
442
433
  }
443
434
  : rawElement;
@@ -460,6 +451,7 @@ function normalizeJsonRenderInput(spec: unknown): unknown {
460
451
  elements: {
461
452
  root: {
462
453
  ...specRecord,
454
+ visible: 'visible' in specRecord ? specRecord.visible : true,
463
455
  children: Array.isArray(specRecord.children)
464
456
  ? specRecord.children.filter((child: unknown) => typeof child === 'string')
465
457
  : [],
@@ -600,6 +592,30 @@ export function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec {
600
592
  });
601
593
  }
602
594
 
595
+ export function buildGraphConfig(input: GraphNodeInput): Record<string, unknown> {
596
+ const title = input.title?.trim() || 'Graph';
597
+ return {
598
+ title,
599
+ graphType: input.graphType,
600
+ data: input.data,
601
+ ...(input.xKey ? { xKey: input.xKey } : {}),
602
+ ...(input.yKey ? { yKey: input.yKey } : {}),
603
+ ...(input.zKey ? { zKey: input.zKey } : {}),
604
+ ...(input.nameKey ? { nameKey: input.nameKey } : {}),
605
+ ...(input.valueKey ? { valueKey: input.valueKey } : {}),
606
+ ...(input.axisKey ? { axisKey: input.axisKey } : {}),
607
+ ...(input.metrics?.length ? { metrics: input.metrics } : {}),
608
+ ...(input.series?.length ? { series: input.series } : {}),
609
+ ...(input.barKey ? { barKey: input.barKey } : {}),
610
+ ...(input.lineKey ? { lineKey: input.lineKey } : {}),
611
+ ...(input.aggregate ? { aggregate: input.aggregate } : {}),
612
+ ...(input.color ? { color: input.color } : {}),
613
+ ...(input.barColor ? { barColor: input.barColor } : {}),
614
+ ...(input.lineColor ? { lineColor: input.lineColor } : {}),
615
+ ...(typeof input.height === 'number' ? { height: input.height } : {}),
616
+ };
617
+ }
618
+
603
619
  export function createJsonRenderNodeData(
604
620
  nodeId: string,
605
621
  title: string,
package/src/mcp/server.ts CHANGED
@@ -95,7 +95,9 @@ function encodeBase64(bytes: Uint8Array): string {
95
95
 
96
96
  function createdNodePayload(c: PmxCanvas, id: string): Record<string, unknown> {
97
97
  const node = c.getNode(id);
98
- return node ? { ok: true, ...serializeCanvasNode(node) } : { ok: true, id };
98
+ if (!node) return { ok: true, id };
99
+ const serialized = serializeCanvasNode(node);
100
+ return { ok: true, node: serialized, ...serialized };
99
101
  }
100
102
 
101
103
  export async function startMcpServer(): Promise<void> {
@@ -576,10 +578,16 @@ export async function startMcpServer(): Promise<void> {
576
578
  y: z.number().optional().describe('New Y position'),
577
579
  width: z.number().optional().describe('New width'),
578
580
  height: z.number().optional().describe('New height'),
581
+ spec: z.record(z.string(), z.unknown()).optional().describe('New json-render spec, or a graph payload with graphType/data for graph nodes'),
582
+ graphType: z.string().optional().describe('Graph type when updating a graph node'),
583
+ data: z.array(z.record(z.string(), z.unknown())).optional().describe('Graph dataset when updating a graph node'),
584
+ xKey: z.string().optional().describe('Graph x/category key'),
585
+ yKey: z.string().optional().describe('Graph y/value key'),
586
+ chartHeight: z.number().optional().describe('Graph chart content height, distinct from node height'),
579
587
  collapsed: z.boolean().optional().describe('Collapse or expand the node'),
580
588
  arrangeLocked: z.boolean().optional().describe('Prevent auto-arrange from moving this node. Pinned nodes are also skipped.'),
581
589
  },
582
- async ({ id, title, content, x, y, width, height, collapsed, arrangeLocked }) => {
590
+ async ({ id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, arrangeLocked }) => {
583
591
  const c = await ensureCanvas();
584
592
  const node = c.getNode(id);
585
593
  if (!node) {
@@ -598,22 +606,21 @@ export async function startMcpServer(): Promise<void> {
598
606
  if (collapsed !== undefined) {
599
607
  patch.collapsed = collapsed;
600
608
  }
601
- if (title !== undefined || content !== undefined) {
602
- patch.data = {
603
- ...node.data,
604
- ...(title !== undefined ? { title } : {}),
605
- ...(content !== undefined ? { content } : {}),
606
- };
607
- }
609
+ if (title !== undefined) patch.title = title;
610
+ if (content !== undefined) patch.content = content;
611
+ if (spec !== undefined) patch.spec = spec;
612
+ if (graphType !== undefined) patch.graphType = graphType;
613
+ if (data !== undefined) patch.data = data;
614
+ if (xKey !== undefined) patch.xKey = xKey;
615
+ if (yKey !== undefined) patch.yKey = yKey;
616
+ if (chartHeight !== undefined) patch.chartHeight = chartHeight;
608
617
  if (arrangeLocked !== undefined) {
609
- patch.data = {
610
- ...(patch.data && typeof patch.data === 'object' ? patch.data as Record<string, unknown> : node.data),
611
- arrangeLocked,
612
- };
618
+ patch.arrangeLocked = arrangeLocked;
613
619
  }
614
620
  c.updateNode(id, patch);
621
+ const updated = c.getNode(id);
615
622
  return {
616
- content: [{ type: 'text', text: JSON.stringify({ ok: true, id }) }],
623
+ content: [{ type: 'text', text: JSON.stringify(updated ? createdNodePayload(c, id) : { ok: true, id }, null, 2) }],
617
624
  };
618
625
  },
619
626
  );
@@ -743,6 +750,31 @@ export async function startMcpServer(): Promise<void> {
743
750
  },
744
751
  );
745
752
 
753
+ server.tool(
754
+ 'canvas_fit_view',
755
+ 'Fit the canvas viewport to all nodes or a selected subset. Useful before screenshots and whole-board review.',
756
+ {
757
+ width: z.number().optional().describe('Viewport width used for fit math (default 1440)'),
758
+ height: z.number().optional().describe('Viewport height used for fit math (default 900)'),
759
+ padding: z.number().optional().describe('World-space padding around fitted nodes (default 60)'),
760
+ maxScale: z.number().optional().describe('Maximum zoom scale (default 1)'),
761
+ nodeIds: z.array(z.string()).optional().describe('Optional node IDs to fit instead of the whole canvas'),
762
+ },
763
+ async (input) => {
764
+ const c = await ensureCanvas();
765
+ const result = c.fitView({
766
+ ...(typeof input.width === 'number' ? { width: input.width } : {}),
767
+ ...(typeof input.height === 'number' ? { height: input.height } : {}),
768
+ ...(typeof input.padding === 'number' ? { padding: input.padding } : {}),
769
+ ...(typeof input.maxScale === 'number' ? { maxScale: input.maxScale } : {}),
770
+ ...(Array.isArray(input.nodeIds) ? { nodeIds: input.nodeIds } : {}),
771
+ });
772
+ return {
773
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
774
+ };
775
+ },
776
+ );
777
+
746
778
  // ── canvas_clear ───────────────────────────────────────────────
747
779
  server.tool(
748
780
  'canvas_clear',
@@ -22,6 +22,7 @@ import { searchNodes } from './spatial-analysis.js';
22
22
  import { getCanvasNodeTitle, serializeCanvasNode, type SerializedCanvasNode } from './canvas-serialization.js';
23
23
  import {
24
24
  buildGraphSpec,
25
+ buildGraphConfig,
25
26
  createJsonRenderNodeData,
26
27
  GRAPH_NODE_SIZE,
27
28
  inferJsonRenderNodeTitle,
@@ -42,6 +43,33 @@ import { buildExcalidrawRestoreCheckpointToolInput, ensureExcalidrawCheckpointId
42
43
  export type CanvasArrangeMode = 'grid' | 'column' | 'flow';
43
44
  export type CanvasPinMode = 'set' | 'add' | 'remove';
44
45
 
46
+ export interface CanvasFitViewOptions {
47
+ width?: number;
48
+ height?: number;
49
+ padding?: number;
50
+ maxScale?: number;
51
+ nodeIds?: string[];
52
+ }
53
+
54
+ export interface CanvasFitViewResult {
55
+ ok: true;
56
+ viewport: { x: number; y: number; scale: number };
57
+ nodeCount: number;
58
+ bounds: { x: number; y: number; width: number; height: number } | null;
59
+ }
60
+
61
+ export interface CanvasGraphNodeUpdateInput extends Partial<GraphNodeInput> {
62
+ spec?: unknown;
63
+ type?: string;
64
+ }
65
+
66
+ export interface CanvasStructuredNodeUpdateInput extends Omit<CanvasGraphNodeUpdateInput, 'data'> {
67
+ content?: unknown;
68
+ data?: unknown;
69
+ arrangeLocked?: unknown;
70
+ chartHeight?: unknown;
71
+ }
72
+
45
73
  interface CanvasAddNodeInput {
46
74
  type: CanvasNodeState['type'];
47
75
  title?: string;
@@ -84,6 +112,246 @@ function isRecord(value: unknown): value is Record<string, unknown> {
84
112
  return value !== null && typeof value === 'object' && !Array.isArray(value);
85
113
  }
86
114
 
115
+ function positiveNumber(value: number | undefined, fallback: number): number {
116
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
117
+ }
118
+
119
+ function pickString(record: Record<string, unknown>, key: string): string | undefined {
120
+ const value = record[key];
121
+ return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
122
+ }
123
+
124
+ function pickNumber(record: Record<string, unknown>, key: string): number | undefined {
125
+ const value = record[key];
126
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
127
+ }
128
+
129
+ function pickStringArray(record: Record<string, unknown>, key: string): string[] | undefined {
130
+ const value = record[key];
131
+ if (!Array.isArray(value)) return undefined;
132
+ const strings = value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
133
+ return strings.length > 0 ? strings : undefined;
134
+ }
135
+
136
+ function pickGraphData(record: Record<string, unknown>, key: string): Array<Record<string, unknown>> | undefined {
137
+ const value = record[key];
138
+ if (!Array.isArray(value)) return undefined;
139
+ const rows = value.filter((item): item is Record<string, unknown> => isRecord(item));
140
+ return rows.length === value.length ? rows : undefined;
141
+ }
142
+
143
+ function pickAggregate(record: Record<string, unknown>, key: string): GraphNodeInput['aggregate'] | undefined {
144
+ const value = record[key];
145
+ return value === 'sum' || value === 'count' || value === 'avg' ? value : undefined;
146
+ }
147
+
148
+ function isJsonRenderSpecLike(value: unknown): boolean {
149
+ return isRecord(value) && typeof value.root === 'string' && isRecord(value.elements);
150
+ }
151
+
152
+ function isGraphPayloadLike(value: unknown): value is Record<string, unknown> {
153
+ return isRecord(value) && !isJsonRenderSpecLike(value) && (
154
+ Array.isArray(value.data) || typeof value.graphType === 'string'
155
+ );
156
+ }
157
+
158
+ function hasGraphUpdateFields(input: Record<string, unknown>): boolean {
159
+ return input.graphType !== undefined ||
160
+ input.type !== undefined ||
161
+ Array.isArray(input.data) ||
162
+ input.xKey !== undefined ||
163
+ input.yKey !== undefined ||
164
+ input.zKey !== undefined ||
165
+ input.nameKey !== undefined ||
166
+ input.valueKey !== undefined ||
167
+ input.axisKey !== undefined ||
168
+ input.metrics !== undefined ||
169
+ input.series !== undefined ||
170
+ input.barKey !== undefined ||
171
+ input.lineKey !== undefined ||
172
+ input.aggregate !== undefined ||
173
+ input.color !== undefined ||
174
+ input.barColor !== undefined ||
175
+ input.lineColor !== undefined ||
176
+ input.chartHeight !== undefined;
177
+ }
178
+
179
+ function graphUpdateInput(input: CanvasStructuredNodeUpdateInput): CanvasGraphNodeUpdateInput {
180
+ const data = pickGraphData(input as Record<string, unknown>, 'data');
181
+ const {
182
+ data: _data,
183
+ content: _content,
184
+ arrangeLocked: _arrangeLocked,
185
+ chartHeight,
186
+ ...graphFields
187
+ } = input;
188
+ return {
189
+ ...graphFields,
190
+ ...(data ? { data } : {}),
191
+ ...(typeof chartHeight === 'number' ? { height: chartHeight } : {}),
192
+ };
193
+ }
194
+
195
+ function mergeNodeDataFields(
196
+ base: Record<string, unknown>,
197
+ input: CanvasStructuredNodeUpdateInput,
198
+ ): Record<string, unknown> {
199
+ return {
200
+ ...base,
201
+ ...(isRecord(input.data) ? input.data : {}),
202
+ ...(typeof input.arrangeLocked === 'boolean' ? { arrangeLocked: input.arrangeLocked } : {}),
203
+ };
204
+ }
205
+
206
+ export function hasStructuredNodeUpdateFields(input: Record<string, unknown>): boolean {
207
+ return input.spec !== undefined || hasGraphUpdateFields(input);
208
+ }
209
+
210
+ export function buildStructuredNodeUpdate(
211
+ node: CanvasNodeState,
212
+ input: CanvasStructuredNodeUpdateInput,
213
+ ): { data: Record<string, unknown> } {
214
+ const inputRecord = input as Record<string, unknown>;
215
+ const hasSpec = inputRecord.spec !== undefined;
216
+ const hasGraphFields = hasGraphUpdateFields(inputRecord);
217
+
218
+ if (node.type === 'json-render') {
219
+ if (hasGraphFields) {
220
+ throw new Error(`Graph update fields can only be used with graph nodes, not ${node.type} nodes.`);
221
+ }
222
+ if (!hasSpec) {
223
+ throw new Error('json-render structured updates require a spec.');
224
+ }
225
+ return {
226
+ data: mergeNodeDataFields(buildJsonRenderNodeUpdate(node, {
227
+ ...(typeof input.title === 'string' ? { title: input.title } : {}),
228
+ spec: input.spec,
229
+ }).data, input),
230
+ };
231
+ }
232
+
233
+ if (node.type === 'graph') {
234
+ return {
235
+ data: mergeNodeDataFields(buildGraphNodeUpdate(node, graphUpdateInput(input)).data, input),
236
+ };
237
+ }
238
+
239
+ throw new Error(`Structured spec and graph updates can only be used with json-render or graph nodes, not ${node.type} nodes.`);
240
+ }
241
+
242
+ function graphConfigToInput(config: Record<string, unknown>, fallbackTitle: string): GraphNodeInput | null {
243
+ const data = pickGraphData(config, 'data');
244
+ if (!data) return null;
245
+ return {
246
+ title: pickString(config, 'title') ?? fallbackTitle,
247
+ graphType: pickString(config, 'graphType') ?? 'line',
248
+ data,
249
+ ...(pickString(config, 'xKey') ? { xKey: pickString(config, 'xKey') } : {}),
250
+ ...(pickString(config, 'yKey') ? { yKey: pickString(config, 'yKey') } : {}),
251
+ ...(pickString(config, 'zKey') ? { zKey: pickString(config, 'zKey') } : {}),
252
+ ...(pickString(config, 'nameKey') ? { nameKey: pickString(config, 'nameKey') } : {}),
253
+ ...(pickString(config, 'valueKey') ? { valueKey: pickString(config, 'valueKey') } : {}),
254
+ ...(pickString(config, 'axisKey') ? { axisKey: pickString(config, 'axisKey') } : {}),
255
+ ...(pickStringArray(config, 'metrics') ? { metrics: pickStringArray(config, 'metrics') } : {}),
256
+ ...(pickStringArray(config, 'series') ? { series: pickStringArray(config, 'series') } : {}),
257
+ ...(pickString(config, 'barKey') ? { barKey: pickString(config, 'barKey') } : {}),
258
+ ...(pickString(config, 'lineKey') ? { lineKey: pickString(config, 'lineKey') } : {}),
259
+ ...(pickAggregate(config, 'aggregate') ? { aggregate: pickAggregate(config, 'aggregate') } : {}),
260
+ ...(pickString(config, 'color') ? { color: pickString(config, 'color') } : {}),
261
+ ...(pickString(config, 'barColor') ? { barColor: pickString(config, 'barColor') } : {}),
262
+ ...(pickString(config, 'lineColor') ? { lineColor: pickString(config, 'lineColor') } : {}),
263
+ ...(pickNumber(config, 'height') !== undefined ? { height: pickNumber(config, 'height') } : {}),
264
+ };
265
+ }
266
+
267
+ function mergeGraphInput(source: Record<string, unknown>, fallback: GraphNodeInput | null): GraphNodeInput {
268
+ const data = pickGraphData(source, 'data') ?? fallback?.data;
269
+ if (!data) throw new Error('Graph update requires a data array, either in the update payload or the existing graphConfig.');
270
+ return {
271
+ title: pickString(source, 'title') ?? fallback?.title ?? 'Graph',
272
+ graphType: pickString(source, 'graphType') ?? pickString(source, 'type') ?? fallback?.graphType ?? 'line',
273
+ data,
274
+ ...((pickString(source, 'xKey') ?? fallback?.xKey) ? { xKey: pickString(source, 'xKey') ?? fallback?.xKey } : {}),
275
+ ...((pickString(source, 'yKey') ?? fallback?.yKey) ? { yKey: pickString(source, 'yKey') ?? fallback?.yKey } : {}),
276
+ ...((pickString(source, 'zKey') ?? fallback?.zKey) ? { zKey: pickString(source, 'zKey') ?? fallback?.zKey } : {}),
277
+ ...((pickString(source, 'nameKey') ?? fallback?.nameKey) ? { nameKey: pickString(source, 'nameKey') ?? fallback?.nameKey } : {}),
278
+ ...((pickString(source, 'valueKey') ?? fallback?.valueKey) ? { valueKey: pickString(source, 'valueKey') ?? fallback?.valueKey } : {}),
279
+ ...((pickString(source, 'axisKey') ?? fallback?.axisKey) ? { axisKey: pickString(source, 'axisKey') ?? fallback?.axisKey } : {}),
280
+ ...((pickStringArray(source, 'metrics') ?? fallback?.metrics) ? { metrics: pickStringArray(source, 'metrics') ?? fallback?.metrics } : {}),
281
+ ...((pickStringArray(source, 'series') ?? fallback?.series) ? { series: pickStringArray(source, 'series') ?? fallback?.series } : {}),
282
+ ...((pickString(source, 'barKey') ?? fallback?.barKey) ? { barKey: pickString(source, 'barKey') ?? fallback?.barKey } : {}),
283
+ ...((pickString(source, 'lineKey') ?? fallback?.lineKey) ? { lineKey: pickString(source, 'lineKey') ?? fallback?.lineKey } : {}),
284
+ ...((pickAggregate(source, 'aggregate') ?? fallback?.aggregate) ? { aggregate: pickAggregate(source, 'aggregate') ?? fallback?.aggregate } : {}),
285
+ ...((pickString(source, 'color') ?? fallback?.color) ? { color: pickString(source, 'color') ?? fallback?.color } : {}),
286
+ ...((pickString(source, 'barColor') ?? fallback?.barColor) ? { barColor: pickString(source, 'barColor') ?? fallback?.barColor } : {}),
287
+ ...((pickString(source, 'lineColor') ?? fallback?.lineColor) ? { lineColor: pickString(source, 'lineColor') ?? fallback?.lineColor } : {}),
288
+ ...((pickNumber(source, 'height') ?? fallback?.height) !== undefined ? { height: pickNumber(source, 'height') ?? fallback?.height } : {}),
289
+ };
290
+ }
291
+
292
+ export function buildJsonRenderNodeUpdate(
293
+ node: CanvasNodeState,
294
+ input: { title?: string; spec: unknown },
295
+ ): { data: Record<string, unknown>; spec: JsonRenderSpec } {
296
+ if (node.type !== 'json-render') throw new Error(`Node "${node.id}" is not a json-render node.`);
297
+ const spec = normalizeAndValidateJsonRenderSpec(input.spec);
298
+ const title = input.title?.trim() || inferJsonRenderNodeTitle(spec);
299
+ return {
300
+ spec,
301
+ data: {
302
+ ...node.data,
303
+ ...createJsonRenderNodeData(node.id, title, spec, { viewerType: 'json-render' }),
304
+ },
305
+ };
306
+ }
307
+
308
+ export function buildGraphNodeUpdate(
309
+ node: CanvasNodeState,
310
+ input: CanvasGraphNodeUpdateInput,
311
+ ): { data: Record<string, unknown>; spec: JsonRenderSpec; graphConfig: Record<string, unknown> } {
312
+ if (node.type !== 'graph') throw new Error(`Node "${node.id}" is not a graph node.`);
313
+ const currentConfig = isRecord(node.data.graphConfig) ? node.data.graphConfig : {};
314
+ const fallbackTitle = typeof node.data.title === 'string' ? node.data.title : 'Graph';
315
+ const fallback = graphConfigToInput(currentConfig, fallbackTitle);
316
+ const source = isGraphPayloadLike(input.spec)
317
+ ? input.spec
318
+ : Object.fromEntries(
319
+ Object.entries(input).filter(([key, value]) => key !== 'spec' && value !== undefined),
320
+ );
321
+
322
+ if (input.spec !== undefined && !isGraphPayloadLike(input.spec)) {
323
+ const spec = normalizeAndValidateJsonRenderSpec(input.spec);
324
+ const title = input.title?.trim() || fallbackTitle;
325
+ return {
326
+ spec,
327
+ graphConfig: currentConfig,
328
+ data: {
329
+ ...node.data,
330
+ ...createJsonRenderNodeData(node.id, title, spec, {
331
+ viewerType: 'graph',
332
+ graphConfig: currentConfig,
333
+ }),
334
+ },
335
+ };
336
+ }
337
+
338
+ const graphInput = mergeGraphInput(source, fallback);
339
+ const spec = buildGraphSpec(graphInput);
340
+ const graphConfig = buildGraphConfig(graphInput);
341
+ const title = graphInput.title?.trim() || 'Graph';
342
+ return {
343
+ spec,
344
+ graphConfig,
345
+ data: {
346
+ ...node.data,
347
+ ...createJsonRenderNodeData(node.id, title, spec, {
348
+ viewerType: 'graph',
349
+ graphConfig,
350
+ }),
351
+ },
352
+ };
353
+ }
354
+
87
355
  function getStoredExcalidrawCheckpointId(node: CanvasNodeState): string | null {
88
356
  const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
89
357
  const checkpointId = appCheckpoint?.id;
@@ -1033,26 +1301,7 @@ export function createCanvasGraphNode(
1033
1301
  dockPosition: null,
1034
1302
  data: createJsonRenderNodeData(id, title, spec, {
1035
1303
  viewerType: 'graph',
1036
- graphConfig: {
1037
- title,
1038
- graphType: input.graphType,
1039
- data: input.data,
1040
- ...(input.xKey ? { xKey: input.xKey } : {}),
1041
- ...(input.yKey ? { yKey: input.yKey } : {}),
1042
- ...(input.zKey ? { zKey: input.zKey } : {}),
1043
- ...(input.nameKey ? { nameKey: input.nameKey } : {}),
1044
- ...(input.valueKey ? { valueKey: input.valueKey } : {}),
1045
- ...(input.axisKey ? { axisKey: input.axisKey } : {}),
1046
- ...(input.metrics?.length ? { metrics: input.metrics } : {}),
1047
- ...(input.series?.length ? { series: input.series } : {}),
1048
- ...(input.barKey ? { barKey: input.barKey } : {}),
1049
- ...(input.lineKey ? { lineKey: input.lineKey } : {}),
1050
- ...(input.aggregate ? { aggregate: input.aggregate } : {}),
1051
- ...(input.color ? { color: input.color } : {}),
1052
- ...(input.barColor ? { barColor: input.barColor } : {}),
1053
- ...(input.lineColor ? { lineColor: input.lineColor } : {}),
1054
- ...(typeof input.height === 'number' ? { height: input.height } : {}),
1055
- },
1304
+ graphConfig: buildGraphConfig(input),
1056
1305
  }),
1057
1306
  };
1058
1307
 
@@ -1060,6 +1309,46 @@ export function createCanvasGraphNode(
1060
1309
  return { id, url: String(node.data.url), spec, node };
1061
1310
  }
1062
1311
 
1312
+ export function fitCanvasView(options: CanvasFitViewOptions = {}): CanvasFitViewResult {
1313
+ const width = positiveNumber(options.width, 1440);
1314
+ const height = positiveNumber(options.height, 900);
1315
+ const padding = positiveNumber(options.padding, 60);
1316
+ const maxScale = positiveNumber(options.maxScale, 1);
1317
+ const nodeIdFilter = options.nodeIds && options.nodeIds.length > 0 ? new Set(options.nodeIds) : null;
1318
+ const targetNodes = canvasState.getLayout().nodes.filter((node) => !nodeIdFilter || nodeIdFilter.has(node.id));
1319
+
1320
+ if (targetNodes.length === 0) {
1321
+ const viewport = canvasState.viewport;
1322
+ return { ok: true, viewport, nodeCount: 0, bounds: null };
1323
+ }
1324
+
1325
+ let minX = Number.POSITIVE_INFINITY;
1326
+ let minY = Number.POSITIVE_INFINITY;
1327
+ let maxX = Number.NEGATIVE_INFINITY;
1328
+ let maxY = Number.NEGATIVE_INFINITY;
1329
+ for (const node of targetNodes) {
1330
+ minX = Math.min(minX, node.position.x);
1331
+ minY = Math.min(minY, node.position.y);
1332
+ maxX = Math.max(maxX, node.position.x + node.size.width);
1333
+ maxY = Math.max(maxY, node.position.y + node.size.height);
1334
+ }
1335
+
1336
+ const bounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
1337
+ const worldWidth = Math.max(1, bounds.width + padding * 2);
1338
+ const worldHeight = Math.max(1, bounds.height + padding * 2);
1339
+ const scale = Math.min(maxScale, width / worldWidth, height / worldHeight);
1340
+ const centerX = minX + bounds.width / 2;
1341
+ const centerY = minY + bounds.height / 2;
1342
+ const viewport = {
1343
+ x: width / 2 - centerX * scale,
1344
+ y: height / 2 - centerY * scale,
1345
+ scale,
1346
+ };
1347
+
1348
+ canvasState.setViewport(viewport);
1349
+ return { ok: true, viewport: canvasState.viewport, nodeCount: targetNodes.length, bounds };
1350
+ }
1351
+
1063
1352
  function isPlainRecord(value: unknown): value is Record<string, unknown> {
1064
1353
  return !!value && typeof value === 'object' && !Array.isArray(value);
1065
1354
  }
@@ -439,6 +439,8 @@ export function describeCanvasSchema(): {
439
439
  'canvas_build_web_artifact',
440
440
  'canvas_open_mcp_app',
441
441
  'canvas_create_group',
442
+ 'canvas_update_node',
443
+ 'canvas_fit_view',
442
444
  'canvas_describe_schema',
443
445
  'canvas_validate_spec',
444
446
  ],
@@ -49,6 +49,13 @@ function fullyContains(group: CanvasNodeState, child: CanvasNodeState): boolean
49
49
  );
50
50
  }
51
51
 
52
+ function isGroupChildPair(group: CanvasNodeState, child: CanvasNodeState): boolean {
53
+ if (group.type !== 'group') return false;
54
+ if (child.data.parentGroup === group.id) return true;
55
+ const children = group.data.children;
56
+ return Array.isArray(children) && children.includes(child.id);
57
+ }
58
+
52
59
  function pair(a: CanvasNodeState, b: CanvasNodeState): CanvasValidationPair {
53
60
  return {
54
61
  aId: a.id,
@@ -78,11 +85,11 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
78
85
  const b = layout.nodes[j]!;
79
86
  if (!overlaps(a, b)) continue;
80
87
 
81
- if (a.type === 'group' && b.data.parentGroup === a.id) {
88
+ if (isGroupChildPair(a, b)) {
82
89
  (fullyContains(a, b) ? containments : containmentViolations).push(containment(a, b));
83
90
  continue;
84
91
  }
85
- if (b.type === 'group' && a.data.parentGroup === b.id) {
92
+ if (isGroupChildPair(b, a)) {
86
93
  (fullyContains(b, a) ? containments : containmentViolations).push(containment(b, a));
87
94
  continue;
88
95
  }
@@ -115,6 +115,52 @@ function elementHasCameraUpdate(elements: Array<Record<string, unknown>>): boole
115
115
  return elements.some((element) => element.type === 'cameraUpdate');
116
116
  }
117
117
 
118
+ function normalizeExcalidrawBoundText(elements: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
119
+ const elementsById = new Map<string, Record<string, unknown>>();
120
+ for (const element of elements) {
121
+ if (typeof element.id === 'string') elementsById.set(element.id, element);
122
+ }
123
+
124
+ let changed = false;
125
+ const boundElementIdsByContainer = new Map<string, Set<string>>();
126
+
127
+ for (const element of elements) {
128
+ if (element.type !== 'text' || typeof element.id !== 'string' || typeof element.containerId !== 'string') continue;
129
+ if (!elementsById.has(element.containerId)) continue;
130
+ const ids = boundElementIdsByContainer.get(element.containerId) ?? new Set<string>();
131
+ ids.add(element.id);
132
+ boundElementIdsByContainer.set(element.containerId, ids);
133
+ }
134
+
135
+ const normalized = elements.map((element) => {
136
+ if (typeof element.id !== 'string') return element;
137
+ const boundTextIds = boundElementIdsByContainer.get(element.id);
138
+ if (!boundTextIds || boundTextIds.size === 0) return element;
139
+
140
+ const existing = Array.isArray(element.boundElements)
141
+ ? element.boundElements.filter(isRecord)
142
+ : [];
143
+ const existingTextIds = new Set(
144
+ existing
145
+ .filter((boundElement) => boundElement.type === 'text' && typeof boundElement.id === 'string')
146
+ .map((boundElement) => boundElement.id as string),
147
+ );
148
+ const missing = [...boundTextIds].filter((id) => !existingTextIds.has(id));
149
+ if (missing.length === 0) return element;
150
+
151
+ changed = true;
152
+ return {
153
+ ...element,
154
+ boundElements: [
155
+ ...existing,
156
+ ...missing.map((id) => ({ type: 'text', id })),
157
+ ],
158
+ };
159
+ });
160
+
161
+ return changed ? normalized : elements;
162
+ }
163
+
118
164
  function resolveExcalidrawCameraSize(width: number, height: number): { width: number; height: number } {
119
165
  const requiredWidth = Math.max(EXCALIDRAW_MIN_CAMERA_WIDTH, width);
120
166
  const requiredHeight = Math.max(EXCALIDRAW_MIN_CAMERA_HEIGHT, height);
@@ -211,13 +257,13 @@ export function normalizeExcalidrawElements(elements: unknown): string {
211
257
  export function normalizeExcalidrawElementsForToolInput(elements: unknown): string {
212
258
  const parsed = parseExcalidrawElements(elements);
213
259
  const seeded = parsed.length > 0 ? parsed : [...DEFAULT_EXCALIDRAW_ELEMENTS];
214
- return JSON.stringify(withInferredCameraUpdate(seeded));
260
+ return JSON.stringify(withInferredCameraUpdate(normalizeExcalidrawBoundText(seeded)));
215
261
  }
216
262
 
217
263
  export function normalizeExcalidrawCheckpointDataForToolInput(data: unknown): string | null {
218
264
  const elements = parseExcalidrawCheckpointElements(data);
219
265
 
220
- return elements ? JSON.stringify(withInferredCameraUpdate(elements)) : null;
266
+ return elements ? JSON.stringify(withInferredCameraUpdate(normalizeExcalidrawBoundText(elements))) : null;
221
267
  }
222
268
 
223
269
  export function buildExcalidrawRestoreCheckpointToolInput(checkpointId: string, data?: unknown): string {