pmx-canvas 0.1.26 → 0.1.27
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/.github/extensions/pmx-canvas/extension.mjs +191 -0
- package/CHANGELOG.md +74 -0
- package/Readme.md +74 -27
- package/dist/canvas/index.js +82 -82
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +944 -164
- package/dist/types/json-render/catalog.d.ts +195 -20
- package/dist/types/json-render/charts/components.d.ts +7 -0
- package/dist/types/json-render/charts/definitions.d.ts +13 -1
- package/dist/types/json-render/charts/tufte-components.d.ts +65 -0
- package/dist/types/json-render/charts/tufte-definitions.d.ts +164 -0
- package/dist/types/json-render/directives.d.ts +23 -0
- package/dist/types/json-render/renderer/index.d.ts +1 -0
- package/dist/types/json-render/server.d.ts +32 -1
- package/dist/types/mcp/canvas-access.d.ts +62 -0
- package/dist/types/server/ax-state.d.ts +170 -0
- package/dist/types/server/canvas-db.d.ts +17 -1
- package/dist/types/server/canvas-operations.d.ts +45 -0
- package/dist/types/server/canvas-schema.d.ts +5 -1
- package/dist/types/server/canvas-state.d.ts +95 -4
- package/dist/types/server/index.d.ts +114 -2
- package/dist/types/server/mutation-history.d.ts +1 -1
- package/docs/cli.md +42 -0
- package/docs/http-api.md +64 -0
- package/docs/mcp.md +23 -5
- package/docs/node-types.md +1 -1
- package/docs/screenshots/codex-app.png +0 -0
- package/docs/screenshots/github-copilot-app.png +0 -0
- package/docs/sdk.md +19 -1
- package/package.json +10 -7
- package/skills/control-session-orchestrator/SKILL.md +359 -0
- package/skills/control-session-orchestrator/evals/evals.json +75 -0
- package/skills/data-analysis/SKILL.md +6 -0
- package/skills/pmx-canvas/SKILL.md +50 -4
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +6 -0
- package/skills/tufte-viz/SKILL.md +157 -0
- package/skills/tufte-viz/references/analytical-design.md +217 -0
- package/skills/tufte-viz/references/tufte-principles.md +147 -0
- package/src/cli/agent.ts +280 -2
- package/src/cli/index.ts +2 -1
- package/src/client/nodes/ExtAppFrame.tsx +23 -1
- package/src/client/nodes/McpAppNode.tsx +6 -2
- package/src/json-render/catalog.ts +22 -1
- package/src/json-render/charts/components.tsx +97 -10
- package/src/json-render/charts/definitions.ts +19 -2
- package/src/json-render/charts/extra-components.tsx +5 -4
- package/src/json-render/charts/tufte-components.tsx +383 -0
- package/src/json-render/charts/tufte-definitions.ts +128 -0
- package/src/json-render/directives.ts +29 -0
- package/src/json-render/renderer/index.css +101 -0
- package/src/json-render/renderer/index.tsx +33 -0
- package/src/json-render/server.ts +257 -5
- package/src/mcp/canvas-access.ts +261 -0
- package/src/mcp/server.ts +496 -7
- package/src/server/ax-context.ts +8 -3
- package/src/server/ax-state.ts +447 -0
- package/src/server/canvas-db.ts +184 -1
- package/src/server/canvas-operations.ts +107 -0
- package/src/server/canvas-schema.ts +26 -3
- package/src/server/canvas-state.ts +349 -2
- package/src/server/index.ts +234 -2
- package/src/server/mutation-history.ts +6 -0
- package/src/server/server.ts +419 -2
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Definitions for the Tufte primitive chart components in ./tufte-components.tsx.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from ./definitions.ts and ./extra-definitions.ts so the
|
|
5
|
+
* original chart catalogs stay untouched and the merge in ./catalog.ts is the
|
|
6
|
+
* only contact surface.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
export const tufteChartComponentDefinitions = {
|
|
12
|
+
Sparkline: {
|
|
13
|
+
props: z.object({
|
|
14
|
+
title: z.string().nullable(),
|
|
15
|
+
data: z.array(z.record(z.string(), z.unknown())),
|
|
16
|
+
valueKey: z.string(),
|
|
17
|
+
color: z.string().nullable(),
|
|
18
|
+
fill: z.boolean().nullable(),
|
|
19
|
+
showEndDot: z.boolean().nullable(),
|
|
20
|
+
showMinMax: z.boolean().nullable(),
|
|
21
|
+
showValue: z.boolean().nullable(),
|
|
22
|
+
height: z.number().nullable(),
|
|
23
|
+
}),
|
|
24
|
+
description:
|
|
25
|
+
'Word-sized sparkline: a single trend line with no axes, grid, or labels. Optional end dot, min/max markers, light area fill, and an inline last value. The canonical Tufte primitive for showing a trajectory in minimal space.',
|
|
26
|
+
example: {
|
|
27
|
+
title: 'Latency p95',
|
|
28
|
+
data: [{ t: 0, ms: 120 }, { t: 1, ms: 138 }, { t: 2, ms: 117 }, { t: 3, ms: 152 }, { t: 4, ms: 109 }],
|
|
29
|
+
valueKey: 'ms',
|
|
30
|
+
color: null,
|
|
31
|
+
fill: true,
|
|
32
|
+
showEndDot: true,
|
|
33
|
+
showMinMax: false,
|
|
34
|
+
showValue: true,
|
|
35
|
+
height: null,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
DotPlot: {
|
|
40
|
+
props: z.object({
|
|
41
|
+
title: z.string().nullable(),
|
|
42
|
+
data: z.array(z.record(z.string(), z.unknown())),
|
|
43
|
+
labelKey: z.string(),
|
|
44
|
+
valueKey: z.string(),
|
|
45
|
+
color: z.string().nullable(),
|
|
46
|
+
sort: z.enum(['asc', 'desc', 'none']).nullable(),
|
|
47
|
+
height: z.number().nullable(),
|
|
48
|
+
}),
|
|
49
|
+
description:
|
|
50
|
+
'Cleveland dot plot: categorical labels down the Y axis, one dot per category positioned by value on X. Higher data-ink ratio than a bar chart for ranked comparison. Sorts descending by default.',
|
|
51
|
+
example: {
|
|
52
|
+
title: 'Build time by package',
|
|
53
|
+
data: [
|
|
54
|
+
{ pkg: 'core', seconds: 42 },
|
|
55
|
+
{ pkg: 'client', seconds: 31 },
|
|
56
|
+
{ pkg: 'mcp', seconds: 18 },
|
|
57
|
+
{ pkg: 'cli', seconds: 9 },
|
|
58
|
+
],
|
|
59
|
+
labelKey: 'pkg',
|
|
60
|
+
valueKey: 'seconds',
|
|
61
|
+
color: null,
|
|
62
|
+
sort: 'desc',
|
|
63
|
+
height: null,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
BulletChart: {
|
|
68
|
+
props: z.object({
|
|
69
|
+
title: z.string().nullable(),
|
|
70
|
+
data: z.array(z.record(z.string(), z.unknown())),
|
|
71
|
+
labelKey: z.string().nullable(),
|
|
72
|
+
valueKey: z.string(),
|
|
73
|
+
targetKey: z.string().nullable(),
|
|
74
|
+
rangesKey: z.string().nullable(),
|
|
75
|
+
color: z.string().nullable(),
|
|
76
|
+
height: z.number().nullable(),
|
|
77
|
+
}),
|
|
78
|
+
description:
|
|
79
|
+
"Stephen Few's bullet graph: a measure bar against grayscale qualitative bands with a target tick and per-row scale ticks. Compact KPI-vs-target display. Provide per-row `ranges` (ascending band thresholds) and `target`.",
|
|
80
|
+
example: {
|
|
81
|
+
title: 'Quarterly KPIs vs target',
|
|
82
|
+
data: [
|
|
83
|
+
{ label: 'Revenue', value: 84, target: 90, ranges: [50, 75, 100] },
|
|
84
|
+
{ label: 'NPS', value: 67, target: 60, ranges: [40, 60, 80] },
|
|
85
|
+
{ label: 'Uptime', value: 99, target: 99.9, ranges: [95, 99, 100] },
|
|
86
|
+
],
|
|
87
|
+
labelKey: 'label',
|
|
88
|
+
valueKey: 'value',
|
|
89
|
+
targetKey: 'target',
|
|
90
|
+
rangesKey: 'ranges',
|
|
91
|
+
color: null,
|
|
92
|
+
height: null,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
Slopegraph: {
|
|
97
|
+
props: z.object({
|
|
98
|
+
title: z.string().nullable(),
|
|
99
|
+
data: z.array(z.record(z.string(), z.unknown())),
|
|
100
|
+
labelKey: z.string(),
|
|
101
|
+
beforeKey: z.string(),
|
|
102
|
+
afterKey: z.string(),
|
|
103
|
+
beforeLabel: z.string().nullable(),
|
|
104
|
+
afterLabel: z.string().nullable(),
|
|
105
|
+
color: z.string().nullable(),
|
|
106
|
+
colorByDirection: z.boolean().nullable(),
|
|
107
|
+
height: z.number().nullable(),
|
|
108
|
+
}),
|
|
109
|
+
description:
|
|
110
|
+
"Tufte slopegraph: two value columns (before/after) with a connecting line per category. Lines use one neutral ink by default; set colorByDirection to accent rising lines and mute falling ones. Ideal for paired change across many items.",
|
|
111
|
+
example: {
|
|
112
|
+
title: 'Coverage before/after refactor',
|
|
113
|
+
data: [
|
|
114
|
+
{ module: 'auth', before: 62, after: 81 },
|
|
115
|
+
{ module: 'canvas', before: 74, after: 78 },
|
|
116
|
+
{ module: 'mcp', before: 55, after: 49 },
|
|
117
|
+
],
|
|
118
|
+
labelKey: 'module',
|
|
119
|
+
beforeKey: 'before',
|
|
120
|
+
afterKey: 'after',
|
|
121
|
+
beforeLabel: 'Before',
|
|
122
|
+
afterLabel: 'After',
|
|
123
|
+
color: null,
|
|
124
|
+
colorByDirection: null,
|
|
125
|
+
height: null,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
} as const;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom json-render directives available in PMX Canvas specs.
|
|
3
|
+
*
|
|
4
|
+
* Directives let agent specs declare formatting/derivation ($format, $math,
|
|
5
|
+
* $concat, $count, $truncate, $pluralize, $join) instead of pre-formatting
|
|
6
|
+
* strings. Registered into the iframe renderer via <JSONUIProvider directives>.
|
|
7
|
+
*/
|
|
8
|
+
import type { DirectiveDefinition } from '@json-render/core';
|
|
9
|
+
import { standardDirectives } from '@json-render/directives';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Directives enabled in the PMX Canvas viewer. `standardDirectives` covers the
|
|
13
|
+
* seven stateless directives. The $t i18n directive is intentionally omitted —
|
|
14
|
+
* it is a factory requiring locale config and PMX Canvas has no locale source.
|
|
15
|
+
*/
|
|
16
|
+
export const pmxCanvasDirectives: DirectiveDefinition[] = [...standardDirectives];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* True when a prop value is a render-time dynamic expression — a directive
|
|
20
|
+
* (`$format`/`$math`/…) or an existing binding (`$state`/`$item`/`$bindItem`/
|
|
21
|
+
* `$cond`/`$template`/`$computed`). These objects are resolved inside the
|
|
22
|
+
* renderer, so the server-side validators must leave them untouched instead of
|
|
23
|
+
* string-coercing them to `"[object Object]"` or rejecting them as the wrong
|
|
24
|
+
* primitive type.
|
|
25
|
+
*/
|
|
26
|
+
export function isDynamicPropValue(value: unknown): boolean {
|
|
27
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
28
|
+
return Object.keys(value as Record<string, unknown>).some((key) => key.startsWith('$'));
|
|
29
|
+
}
|
|
@@ -204,6 +204,65 @@ button {
|
|
|
204
204
|
color: var(--destructive);
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
.pmx-button {
|
|
208
|
+
display: inline-flex;
|
|
209
|
+
align-items: center;
|
|
210
|
+
justify-content: center;
|
|
211
|
+
gap: 0.5rem;
|
|
212
|
+
height: 2.25rem;
|
|
213
|
+
padding: 0.5rem 1rem;
|
|
214
|
+
border: 1px solid transparent;
|
|
215
|
+
border-radius: var(--radius);
|
|
216
|
+
font-size: 0.875rem;
|
|
217
|
+
font-weight: 500;
|
|
218
|
+
line-height: 1.25rem;
|
|
219
|
+
white-space: nowrap;
|
|
220
|
+
cursor: pointer;
|
|
221
|
+
transition: background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.pmx-button:disabled {
|
|
225
|
+
pointer-events: none;
|
|
226
|
+
opacity: 0.5;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.pmx-button--primary {
|
|
230
|
+
background: var(--primary);
|
|
231
|
+
color: var(--primary-foreground);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.pmx-button--secondary {
|
|
235
|
+
background: var(--secondary);
|
|
236
|
+
color: var(--secondary-foreground);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.pmx-button--destructive,
|
|
240
|
+
.pmx-button--danger {
|
|
241
|
+
background: var(--destructive);
|
|
242
|
+
color: var(--destructive-foreground);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.pmx-button--success {
|
|
246
|
+
background: var(--chart-2);
|
|
247
|
+
color: var(--primary-foreground);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.pmx-button--outline {
|
|
251
|
+
border-color: var(--border);
|
|
252
|
+
background: transparent;
|
|
253
|
+
color: var(--foreground);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.pmx-button--ghost {
|
|
257
|
+
background: transparent;
|
|
258
|
+
color: var(--foreground);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.pmx-button--ghost:hover,
|
|
262
|
+
.pmx-button--outline:hover {
|
|
263
|
+
background: color-mix(in oklch, var(--foreground) 8%, transparent);
|
|
264
|
+
}
|
|
265
|
+
|
|
207
266
|
/* -- Chart components -- */
|
|
208
267
|
|
|
209
268
|
.pmx-chart {
|
|
@@ -225,6 +284,48 @@ button {
|
|
|
225
284
|
min-width: 360px;
|
|
226
285
|
}
|
|
227
286
|
|
|
287
|
+
.pmx-chart--dot-plot,
|
|
288
|
+
.pmx-chart--bullet,
|
|
289
|
+
.pmx-chart--slopegraph {
|
|
290
|
+
min-width: 320px;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.pmx-chart--sparkline {
|
|
294
|
+
min-width: 160px;
|
|
295
|
+
padding: 0.25rem 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.pmx-chart__sparkline-row {
|
|
299
|
+
display: flex;
|
|
300
|
+
align-items: center;
|
|
301
|
+
gap: 0.5rem;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.pmx-chart__sparkline-svg {
|
|
305
|
+
display: block;
|
|
306
|
+
width: 100%;
|
|
307
|
+
height: 36px;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.pmx-chart__sparkline-value {
|
|
311
|
+
font-size: 0.8125rem;
|
|
312
|
+
font-weight: 600;
|
|
313
|
+
font-variant-numeric: tabular-nums;
|
|
314
|
+
white-space: nowrap;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.pmx-chart__dot-plot-svg,
|
|
318
|
+
.pmx-chart__bullet-svg,
|
|
319
|
+
.pmx-chart__slopegraph-svg {
|
|
320
|
+
display: block;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.pmx-chart--dot-plot text,
|
|
324
|
+
.pmx-chart--bullet text,
|
|
325
|
+
.pmx-chart--slopegraph text {
|
|
326
|
+
font-variant-numeric: tabular-nums;
|
|
327
|
+
}
|
|
328
|
+
|
|
228
329
|
.pmx-chart__title {
|
|
229
330
|
font-size: 0.875rem;
|
|
230
331
|
font-weight: 600;
|
|
@@ -14,6 +14,9 @@ import { shadcnComponents } from '@json-render/shadcn';
|
|
|
14
14
|
import { catalog } from '../catalog';
|
|
15
15
|
import { chartComponents } from '../charts/components';
|
|
16
16
|
import { extraChartComponents } from '../charts/extra-components';
|
|
17
|
+
import { tufteChartComponents } from '../charts/tufte-components';
|
|
18
|
+
import { pmxCanvasDirectives } from '../directives';
|
|
19
|
+
import { JsonRenderDevtools } from '@json-render/devtools-react';
|
|
17
20
|
|
|
18
21
|
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'info' | 'warning' | 'error' | 'danger';
|
|
19
22
|
type BadgeProps = {
|
|
@@ -36,12 +39,37 @@ function Badge({ props }: { props: BadgeProps }) {
|
|
|
36
39
|
);
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
type ButtonVariant = 'primary' | 'secondary' | 'destructive' | 'danger' | 'outline' | 'ghost' | 'success';
|
|
43
|
+
type ButtonProps = {
|
|
44
|
+
label: string;
|
|
45
|
+
variant?: ButtonVariant | null;
|
|
46
|
+
disabled?: boolean | null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function Button({ props, emit }: { props: ButtonProps; emit: (event: string) => void }) {
|
|
50
|
+
const resolvedVariant = props.variant ?? 'primary';
|
|
51
|
+
return (
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
data-slot="button"
|
|
55
|
+
data-variant={resolvedVariant}
|
|
56
|
+
className={`pmx-button pmx-button--${resolvedVariant}`}
|
|
57
|
+
disabled={props.disabled ?? false}
|
|
58
|
+
onClick={() => emit('press')}
|
|
59
|
+
>
|
|
60
|
+
{props.label}
|
|
61
|
+
</button>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
const { registry } = defineRegistry(catalog as never, {
|
|
40
66
|
components: {
|
|
41
67
|
...shadcnComponents,
|
|
42
68
|
Badge,
|
|
69
|
+
Button,
|
|
43
70
|
...chartComponents,
|
|
44
71
|
...extraChartComponents,
|
|
72
|
+
...tufteChartComponents,
|
|
45
73
|
} as never,
|
|
46
74
|
});
|
|
47
75
|
|
|
@@ -50,6 +78,7 @@ declare global {
|
|
|
50
78
|
__PMX_CANVAS_JSON_RENDER_SPEC__?: Spec & { state?: Record<string, unknown> };
|
|
51
79
|
__PMX_CANVAS_JSON_RENDER_THEME__?: string;
|
|
52
80
|
__PMX_CANVAS_JSON_RENDER_DISPLAY__?: string;
|
|
81
|
+
__PMX_CANVAS_JSON_RENDER_DEVTOOLS__?: boolean;
|
|
53
82
|
}
|
|
54
83
|
}
|
|
55
84
|
|
|
@@ -95,8 +124,12 @@ function App() {
|
|
|
95
124
|
<JSONUIProvider
|
|
96
125
|
registry={registry}
|
|
97
126
|
initialState={spec.state ?? undefined}
|
|
127
|
+
directives={pmxCanvasDirectives}
|
|
98
128
|
>
|
|
99
129
|
<Renderer spec={spec} registry={registry} loading={false} />
|
|
130
|
+
{window.__PMX_CANVAS_JSON_RENDER_DEVTOOLS__ ? (
|
|
131
|
+
<JsonRenderDevtools position="right" />
|
|
132
|
+
) : null}
|
|
100
133
|
</JSONUIProvider>
|
|
101
134
|
</div>
|
|
102
135
|
);
|
|
@@ -2,7 +2,9 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { buildAppHtml } from '@json-render/mcp/build-app-html';
|
|
5
|
+
import { applySpecPatch, parseSpecStreamLine, type Spec, type SpecStreamLine } from '@json-render/core';
|
|
5
6
|
import { allComponentDefinitions, catalog, validateShadcnElementProps, type JsonRenderIssue } from './catalog.js';
|
|
7
|
+
import { isDynamicPropValue } from './directives.js';
|
|
6
8
|
|
|
7
9
|
export interface JsonRenderSpec {
|
|
8
10
|
root: string;
|
|
@@ -36,8 +38,23 @@ export interface GraphNodeInput {
|
|
|
36
38
|
lineKey?: string;
|
|
37
39
|
aggregate?: 'sum' | 'count' | 'avg';
|
|
38
40
|
color?: string;
|
|
41
|
+
colorBy?: 'series' | 'category' | 'value' | 'none';
|
|
42
|
+
highlight?: number | 'max' | 'min' | null;
|
|
39
43
|
barColor?: string;
|
|
40
44
|
lineColor?: string;
|
|
45
|
+
labelKey?: string;
|
|
46
|
+
beforeKey?: string;
|
|
47
|
+
afterKey?: string;
|
|
48
|
+
beforeLabel?: string;
|
|
49
|
+
afterLabel?: string;
|
|
50
|
+
targetKey?: string;
|
|
51
|
+
rangesKey?: string;
|
|
52
|
+
sort?: 'asc' | 'desc' | 'none';
|
|
53
|
+
fill?: boolean;
|
|
54
|
+
showEndDot?: boolean;
|
|
55
|
+
showMinMax?: boolean;
|
|
56
|
+
showValue?: boolean;
|
|
57
|
+
colorByDirection?: boolean;
|
|
41
58
|
height?: number;
|
|
42
59
|
showLegend?: boolean;
|
|
43
60
|
showLabels?: boolean;
|
|
@@ -59,7 +76,11 @@ export type GraphChartType =
|
|
|
59
76
|
| 'ScatterChart'
|
|
60
77
|
| 'RadarChart'
|
|
61
78
|
| 'StackedBarChart'
|
|
62
|
-
| 'ComposedChart'
|
|
79
|
+
| 'ComposedChart'
|
|
80
|
+
| 'Sparkline'
|
|
81
|
+
| 'DotPlot'
|
|
82
|
+
| 'BulletChart'
|
|
83
|
+
| 'Slopegraph';
|
|
63
84
|
|
|
64
85
|
const GRAPH_TYPE_ALIASES: Record<string, GraphChartType> = {
|
|
65
86
|
line: 'LineChart',
|
|
@@ -84,6 +105,15 @@ const GRAPH_TYPE_ALIASES: Record<string, GraphChartType> = {
|
|
|
84
105
|
composedchart: 'ComposedChart',
|
|
85
106
|
combo: 'ComposedChart',
|
|
86
107
|
combochart: 'ComposedChart',
|
|
108
|
+
sparkline: 'Sparkline',
|
|
109
|
+
spark: 'Sparkline',
|
|
110
|
+
dotplot: 'DotPlot',
|
|
111
|
+
dot: 'DotPlot',
|
|
112
|
+
cleveland: 'DotPlot',
|
|
113
|
+
bullet: 'BulletChart',
|
|
114
|
+
bulletchart: 'BulletChart',
|
|
115
|
+
slopegraph: 'Slopegraph',
|
|
116
|
+
slope: 'Slopegraph',
|
|
87
117
|
};
|
|
88
118
|
|
|
89
119
|
const COERCIBLE_STRING_PROPS = [
|
|
@@ -316,7 +346,12 @@ function normalizeElementProps(
|
|
|
316
346
|
const props = (stripNullishDeep(rawProps) as Record<string, unknown> | undefined) ?? {};
|
|
317
347
|
|
|
318
348
|
for (const key of COERCIBLE_STRING_PROPS) {
|
|
319
|
-
if (
|
|
349
|
+
if (
|
|
350
|
+
key in props &&
|
|
351
|
+
typeof props[key] !== 'string' &&
|
|
352
|
+
props[key] !== undefined &&
|
|
353
|
+
!isDynamicPropValue(props[key])
|
|
354
|
+
) {
|
|
320
355
|
props[key] = String(props[key]);
|
|
321
356
|
}
|
|
322
357
|
}
|
|
@@ -502,6 +537,86 @@ function inferKeysFromData(data: Array<Record<string, unknown>>, exclude: string
|
|
|
502
537
|
return Object.keys(first).filter((key) => !exclude.includes(key));
|
|
503
538
|
}
|
|
504
539
|
|
|
540
|
+
function collectDataKeys(data: Array<Record<string, unknown>>): Set<string> {
|
|
541
|
+
const keys = new Set<string>();
|
|
542
|
+
for (const row of data) {
|
|
543
|
+
for (const key of Object.keys(row)) keys.add(key);
|
|
544
|
+
}
|
|
545
|
+
return keys;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function assertGraphDataKeys(
|
|
549
|
+
data: Array<Record<string, unknown>>,
|
|
550
|
+
chartType: GraphChartType,
|
|
551
|
+
chartProps: Record<string, unknown>,
|
|
552
|
+
): void {
|
|
553
|
+
if (data.length === 0) return;
|
|
554
|
+
const available = collectDataKeys(data);
|
|
555
|
+
const required = new Set<string>();
|
|
556
|
+
|
|
557
|
+
const addString = (value: unknown): void => {
|
|
558
|
+
if (typeof value === 'string' && value.length > 0) required.add(value);
|
|
559
|
+
};
|
|
560
|
+
const addStringList = (value: unknown): void => {
|
|
561
|
+
if (!Array.isArray(value)) return;
|
|
562
|
+
for (const item of value) addString(item);
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
switch (chartType) {
|
|
566
|
+
case 'PieChart':
|
|
567
|
+
addString(chartProps.nameKey);
|
|
568
|
+
addString(chartProps.valueKey);
|
|
569
|
+
break;
|
|
570
|
+
case 'ScatterChart':
|
|
571
|
+
addString(chartProps.xKey);
|
|
572
|
+
addString(chartProps.yKey);
|
|
573
|
+
addString(chartProps.zKey);
|
|
574
|
+
break;
|
|
575
|
+
case 'RadarChart':
|
|
576
|
+
addString(chartProps.axisKey);
|
|
577
|
+
addStringList(chartProps.metrics);
|
|
578
|
+
break;
|
|
579
|
+
case 'StackedBarChart':
|
|
580
|
+
addString(chartProps.xKey);
|
|
581
|
+
addStringList(chartProps.series);
|
|
582
|
+
break;
|
|
583
|
+
case 'ComposedChart':
|
|
584
|
+
addString(chartProps.xKey);
|
|
585
|
+
addString(chartProps.barKey);
|
|
586
|
+
addString(chartProps.lineKey);
|
|
587
|
+
break;
|
|
588
|
+
case 'Sparkline':
|
|
589
|
+
addString(chartProps.valueKey);
|
|
590
|
+
break;
|
|
591
|
+
case 'DotPlot':
|
|
592
|
+
addString(chartProps.labelKey);
|
|
593
|
+
addString(chartProps.valueKey);
|
|
594
|
+
break;
|
|
595
|
+
case 'BulletChart':
|
|
596
|
+
addString(chartProps.labelKey);
|
|
597
|
+
addString(chartProps.valueKey);
|
|
598
|
+
addString(chartProps.targetKey);
|
|
599
|
+
addString(chartProps.rangesKey);
|
|
600
|
+
break;
|
|
601
|
+
case 'Slopegraph':
|
|
602
|
+
addString(chartProps.labelKey);
|
|
603
|
+
addString(chartProps.beforeKey);
|
|
604
|
+
addString(chartProps.afterKey);
|
|
605
|
+
break;
|
|
606
|
+
case 'AreaChart':
|
|
607
|
+
case 'BarChart':
|
|
608
|
+
case 'LineChart':
|
|
609
|
+
addString(chartProps.xKey);
|
|
610
|
+
addString(chartProps.yKey);
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const missing = [...required].filter((key) => !available.has(key));
|
|
615
|
+
if (missing.length === 0) return;
|
|
616
|
+
const availableList = [...available].sort().join(', ') || '(none)';
|
|
617
|
+
throw new Error(`Graph data key mismatch for ${chartType}: missing ${missing.join(', ')}. Available keys: ${availableList}.`);
|
|
618
|
+
}
|
|
619
|
+
|
|
505
620
|
export function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec {
|
|
506
621
|
const title = input.title?.trim() || 'Graph';
|
|
507
622
|
const chartType = normalizeGraphType(input.graphType);
|
|
@@ -567,18 +682,66 @@ export function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec {
|
|
|
567
682
|
chartProps.showLegend = input.showLegend !== false;
|
|
568
683
|
break;
|
|
569
684
|
}
|
|
685
|
+
case 'BarChart': {
|
|
686
|
+
const xKey = input.xKey ?? inferKeysFromData(input.data)[0] ?? 'label';
|
|
687
|
+
const yKey = input.yKey ?? inferKeysFromData(input.data, [xKey])[0] ?? 'value';
|
|
688
|
+
chartProps.xKey = xKey;
|
|
689
|
+
chartProps.yKey = yKey;
|
|
690
|
+
chartProps.aggregate = input.aggregate ?? null;
|
|
691
|
+
chartProps.color = input.color ?? null;
|
|
692
|
+
chartProps.colorBy = input.colorBy ?? 'series';
|
|
693
|
+
chartProps.highlight = input.highlight === undefined ? 'max' : input.highlight;
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
case 'Sparkline': {
|
|
697
|
+
chartProps.valueKey = input.valueKey ?? input.yKey ?? 'value';
|
|
698
|
+
chartProps.color = input.color ?? null;
|
|
699
|
+
chartProps.fill = input.fill ?? null;
|
|
700
|
+
chartProps.showEndDot = input.showEndDot ?? null;
|
|
701
|
+
chartProps.showMinMax = input.showMinMax ?? null;
|
|
702
|
+
chartProps.showValue = input.showValue ?? null;
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
case 'DotPlot': {
|
|
706
|
+
chartProps.labelKey = input.labelKey ?? input.xKey ?? 'label';
|
|
707
|
+
chartProps.valueKey = input.valueKey ?? input.yKey ?? 'value';
|
|
708
|
+
chartProps.color = input.color ?? null;
|
|
709
|
+
chartProps.sort = input.sort ?? null;
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
case 'BulletChart': {
|
|
713
|
+
chartProps.labelKey = input.labelKey ?? input.xKey ?? null;
|
|
714
|
+
chartProps.valueKey = input.valueKey ?? input.yKey ?? 'value';
|
|
715
|
+
chartProps.targetKey = input.targetKey ?? null;
|
|
716
|
+
chartProps.rangesKey = input.rangesKey ?? null;
|
|
717
|
+
chartProps.color = input.color ?? null;
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
case 'Slopegraph': {
|
|
721
|
+
chartProps.labelKey = input.labelKey ?? 'label';
|
|
722
|
+
chartProps.beforeKey = input.beforeKey ?? 'before';
|
|
723
|
+
chartProps.afterKey = input.afterKey ?? 'after';
|
|
724
|
+
chartProps.beforeLabel = input.beforeLabel ?? null;
|
|
725
|
+
chartProps.afterLabel = input.afterLabel ?? null;
|
|
726
|
+
chartProps.color = input.color ?? null;
|
|
727
|
+
chartProps.colorByDirection = input.colorByDirection ?? null;
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
570
730
|
case 'AreaChart':
|
|
571
731
|
case 'LineChart':
|
|
572
|
-
case 'BarChart':
|
|
573
732
|
default: {
|
|
574
|
-
|
|
575
|
-
|
|
733
|
+
const xKey = input.xKey ?? inferKeysFromData(input.data)[0] ?? 'label';
|
|
734
|
+
const yKey = input.yKey ?? inferKeysFromData(input.data, [xKey])[0] ?? 'value';
|
|
735
|
+
chartProps.xKey = xKey;
|
|
736
|
+
chartProps.yKey = yKey;
|
|
576
737
|
chartProps.aggregate = input.aggregate ?? null;
|
|
577
738
|
chartProps.color = input.color ?? null;
|
|
578
739
|
break;
|
|
579
740
|
}
|
|
580
741
|
}
|
|
581
742
|
|
|
743
|
+
assertGraphDataKeys(input.data, chartType, chartProps);
|
|
744
|
+
|
|
582
745
|
return normalizeAndValidateJsonRenderSpec({
|
|
583
746
|
root: 'card',
|
|
584
747
|
elements: {
|
|
@@ -619,14 +782,101 @@ export function buildGraphConfig(input: GraphNodeInput): Record<string, unknown>
|
|
|
619
782
|
...(input.lineKey ? { lineKey: input.lineKey } : {}),
|
|
620
783
|
...(input.aggregate ? { aggregate: input.aggregate } : {}),
|
|
621
784
|
...(input.color ? { color: input.color } : {}),
|
|
785
|
+
...(input.colorBy ? { colorBy: input.colorBy } : {}),
|
|
786
|
+
...(input.highlight !== undefined ? { highlight: input.highlight } : {}),
|
|
622
787
|
...(input.barColor ? { barColor: input.barColor } : {}),
|
|
623
788
|
...(input.lineColor ? { lineColor: input.lineColor } : {}),
|
|
789
|
+
...(input.labelKey ? { labelKey: input.labelKey } : {}),
|
|
790
|
+
...(input.beforeKey ? { beforeKey: input.beforeKey } : {}),
|
|
791
|
+
...(input.afterKey ? { afterKey: input.afterKey } : {}),
|
|
792
|
+
...(input.beforeLabel ? { beforeLabel: input.beforeLabel } : {}),
|
|
793
|
+
...(input.afterLabel ? { afterLabel: input.afterLabel } : {}),
|
|
794
|
+
...(input.targetKey ? { targetKey: input.targetKey } : {}),
|
|
795
|
+
...(input.rangesKey ? { rangesKey: input.rangesKey } : {}),
|
|
796
|
+
...(input.sort ? { sort: input.sort } : {}),
|
|
797
|
+
...(typeof input.fill === 'boolean' ? { fill: input.fill } : {}),
|
|
798
|
+
...(typeof input.showEndDot === 'boolean' ? { showEndDot: input.showEndDot } : {}),
|
|
799
|
+
...(typeof input.showMinMax === 'boolean' ? { showMinMax: input.showMinMax } : {}),
|
|
800
|
+
...(typeof input.showValue === 'boolean' ? { showValue: input.showValue } : {}),
|
|
801
|
+
...(typeof input.colorByDirection === 'boolean' ? { colorByDirection: input.colorByDirection } : {}),
|
|
624
802
|
...(typeof input.height === 'number' ? { height: input.height } : {}),
|
|
625
803
|
...(typeof input.showLegend === 'boolean' ? { showLegend: input.showLegend } : {}),
|
|
626
804
|
...(typeof input.showLabels === 'boolean' ? { showLabels: input.showLabels } : {}),
|
|
627
805
|
};
|
|
628
806
|
}
|
|
629
807
|
|
|
808
|
+
/** The minimal spec a streaming json-render node starts from before any patches. */
|
|
809
|
+
export function emptyStreamingSpec(): JsonRenderSpec {
|
|
810
|
+
return { root: '', elements: {} };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const UNSAFE_PATCH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Reject JSON-Pointer paths that would let a streamed patch reach a prototype
|
|
817
|
+
* chain (`__proto__`/`constructor`/`prototype`). The upstream applySpecPatch
|
|
818
|
+
* walks the path with plain property assignment and has no such guard, so an
|
|
819
|
+
* agent-supplied patch like `{op:'add',path:'/__proto__/x',value:...}` would
|
|
820
|
+
* otherwise pollute Object.prototype server-side. Segments are JSON-Pointer
|
|
821
|
+
* escaped (`~1`->`/`, `~0`->`~`); decode before comparing.
|
|
822
|
+
*/
|
|
823
|
+
function isSafePatchPath(path: string): boolean {
|
|
824
|
+
for (const rawSegment of path.split('/')) {
|
|
825
|
+
const segment = rawSegment.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
826
|
+
if (UNSAFE_PATCH_SEGMENTS.has(segment)) return false;
|
|
827
|
+
}
|
|
828
|
+
return true;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Accept a SpecStream item as either a raw JSONL line or a JSON-Patch object. */
|
|
832
|
+
function coerceStreamPatch(item: unknown): SpecStreamLine | null {
|
|
833
|
+
const patch = (() => {
|
|
834
|
+
if (typeof item === 'string') return parseSpecStreamLine(item);
|
|
835
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
836
|
+
const record = item as Record<string, unknown>;
|
|
837
|
+
if (typeof record.op === 'string' && typeof record.path === 'string') {
|
|
838
|
+
return item as SpecStreamLine;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return null;
|
|
842
|
+
})();
|
|
843
|
+
if (!patch || typeof patch.path !== 'string' || !isSafePatchPath(patch.path)) return null;
|
|
844
|
+
return patch;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Apply a batch of SpecStream patches to the current spec, accumulating the
|
|
849
|
+
* result. The canvas is the source of truth — patches are applied server-side
|
|
850
|
+
* and the browser only renders the current accumulated spec, so there is no
|
|
851
|
+
* client-side reconciliation. Tolerant by design: malformed or inapplicable
|
|
852
|
+
* patches are skipped and counted, never thrown, so a partial stream keeps
|
|
853
|
+
* building toward the final spec.
|
|
854
|
+
*/
|
|
855
|
+
export function applyJsonRenderStreamPatches(
|
|
856
|
+
currentSpec: JsonRenderSpec,
|
|
857
|
+
items: unknown[],
|
|
858
|
+
): { spec: JsonRenderSpec; applied: number; skipped: number } {
|
|
859
|
+
let spec = currentSpec as Spec;
|
|
860
|
+
let applied = 0;
|
|
861
|
+
let skipped = 0;
|
|
862
|
+
for (const item of items) {
|
|
863
|
+
const patch = coerceStreamPatch(item);
|
|
864
|
+
if (!patch) {
|
|
865
|
+
skipped++;
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
try {
|
|
869
|
+
spec = applySpecPatch(spec, patch);
|
|
870
|
+
applied++;
|
|
871
|
+
} catch {
|
|
872
|
+
// Inapplicable patch (e.g. references a not-yet-added element); skip and
|
|
873
|
+
// keep streaming. A later patch may add the missing target.
|
|
874
|
+
skipped++;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return { spec: spec as JsonRenderSpec, applied, skipped };
|
|
878
|
+
}
|
|
879
|
+
|
|
630
880
|
export function createJsonRenderNodeData(
|
|
631
881
|
nodeId: string,
|
|
632
882
|
title: string,
|
|
@@ -649,6 +899,7 @@ export async function buildJsonRenderViewerHtml(options: {
|
|
|
649
899
|
spec: JsonRenderSpec;
|
|
650
900
|
theme?: 'dark' | 'light' | 'high-contrast';
|
|
651
901
|
display?: 'expanded';
|
|
902
|
+
devtools?: boolean;
|
|
652
903
|
}): Promise<string> {
|
|
653
904
|
try {
|
|
654
905
|
await ensureJsonRenderBundle();
|
|
@@ -663,6 +914,7 @@ export async function buildJsonRenderViewerHtml(options: {
|
|
|
663
914
|
`window.__PMX_CANVAS_JSON_RENDER_SPEC__ = ${JSON.stringify(options.spec)};`,
|
|
664
915
|
...(options.theme ? [`window.__PMX_CANVAS_JSON_RENDER_THEME__ = ${JSON.stringify(options.theme)};`] : []),
|
|
665
916
|
...(options.display ? [`window.__PMX_CANVAS_JSON_RENDER_DISPLAY__ = ${JSON.stringify(options.display)};`] : []),
|
|
917
|
+
...(options.devtools ? ['window.__PMX_CANVAS_JSON_RENDER_DEVTOOLS__ = true;'] : []),
|
|
666
918
|
jsBundle,
|
|
667
919
|
].join('\n');
|
|
668
920
|
return buildAppHtml({
|