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.
- package/CHANGELOG.md +136 -0
- package/Readme.md +52 -54
- package/dist/canvas/index.js +52 -52
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +92 -92
- package/dist/types/client/canvas/auto-fit.d.ts +5 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
- package/dist/types/json-render/catalog.d.ts +316 -310
- package/dist/types/json-render/server.d.ts +1 -0
- package/dist/types/server/canvas-operations.d.ts +49 -0
- package/dist/types/server/index.d.ts +9 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +13 -1
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +1 -1
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +11 -0
- package/src/cli/agent.ts +135 -18
- package/src/cli/index.ts +1 -1
- package/src/client/canvas/CanvasNode.tsx +5 -7
- package/src/client/canvas/auto-fit.ts +21 -0
- package/src/client/nodes/ExtAppFrame.tsx +4 -2
- package/src/json-render/catalog.ts +9 -0
- package/src/json-render/renderer/index.css +61 -0
- package/src/json-render/renderer/index.tsx +22 -0
- package/src/json-render/server.ts +27 -11
- package/src/mcp/server.ts +46 -14
- package/src/server/canvas-operations.ts +309 -20
- package/src/server/canvas-schema.ts +2 -0
- package/src/server/canvas-validation.ts +9 -2
- package/src/server/diagram-presets.ts +48 -2
- package/src/server/index.ts +45 -6
- package/src/server/server.ts +149 -35
|
@@ -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
|
-
|
|
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
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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.
|
|
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
|
}
|
|
@@ -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
|
|
88
|
+
if (isGroupChildPair(a, b)) {
|
|
82
89
|
(fullyContains(a, b) ? containments : containmentViolations).push(containment(a, b));
|
|
83
90
|
continue;
|
|
84
91
|
}
|
|
85
|
-
if (b
|
|
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 {
|