@taylorwong/ichartjs 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/LICENSE +201 -0
- package/README.md +194 -0
- package/agent-recipes/diagrams/agent-orchestration.json +18 -0
- package/agent-recipes/diagrams/approval-process.json +16 -0
- package/agent-recipes/diagrams/responsibility-mapping.json +13 -0
- package/agent-recipes/diagrams/workflow.json +19 -0
- package/agent-recipes/foundational-analysis.json +18 -0
- package/agent-recipes/project-management.json +47 -0
- package/agent-recipes/trend-line.json +21 -0
- package/docs/agent/README.md +62 -0
- package/docs/agent/charting-scenario.md +82 -0
- package/docs/agent/coding-agent-integration.md +88 -0
- package/docs/agent/development/2.0-release-readiness.md +65 -0
- package/docs/agent/development/iteration-2.md +9 -0
- package/docs/agent/development/iteration-3.md +142 -0
- package/docs/agent/development/iteration-4.md +261 -0
- package/docs/agent/development/iteration-5.md +43 -0
- package/docs/agent/development/iteration-6.md +130 -0
- package/docs/agent/development/iteration-7.md +117 -0
- package/docs/agent/development/iteration-8-acceptance.md +68 -0
- package/docs/agent/development/iteration-8.md +116 -0
- package/docs/agent/development/iteration-9.md +44 -0
- package/docs/agent/development/playground-plan.md +143 -0
- package/docs/agent/development/prompt-contract.md +22 -0
- package/docs/agent/development/rc-1-acceptance.md +43 -0
- package/docs/agent/development/roadmap.md +259 -0
- package/docs/agent/development-guide.md +60 -0
- package/docs/agent/diagram-scenario.md +82 -0
- package/docs/agent/editing-contract.md +71 -0
- package/docs/agent/frontend-integration.md +66 -0
- package/docs/agent/project-scenario.md +89 -0
- package/docs/agent/quickstart.md +205 -0
- package/docs/agent/runtime-contract.md +132 -0
- package/docs/agent/theme-guide.md +59 -0
- package/docs/agent/zh-CN/README.md +26 -0
- package/docs/agent/zh-CN/charting-scenario.md +62 -0
- package/docs/agent/zh-CN/coding-agent-integration.md +36 -0
- package/docs/agent/zh-CN/development-guide.md +31 -0
- package/docs/agent/zh-CN/diagram-scenario.md +37 -0
- package/docs/agent/zh-CN/editing-contract.md +27 -0
- package/docs/agent/zh-CN/frontend-integration.md +31 -0
- package/docs/agent/zh-CN/project-scenario.md +40 -0
- package/docs/agent/zh-CN/quickstart.md +84 -0
- package/docs/agent/zh-CN/runtime-contract.md +85 -0
- package/docs/agent/zh-CN/theme-guide.md +50 -0
- package/docs/manifests/capabilities.json +79 -0
- package/docs/manifests/commands.json +30 -0
- package/docs/manifests/schemas.json +13 -0
- package/examples/agent-workflow.mjs +94 -0
- package/package.json +55 -0
- package/skills/ichartjs/SKILL.md +72 -0
- package/skills/ichartjs/agents/openai.yaml +4 -0
- package/skills/ichartjs/references/agent-contract.md +39 -0
- package/skills/ichartjs/references/chart-selection.md +22 -0
- package/src/capabilities.mjs +190 -0
- package/src/charts.mjs +234 -0
- package/src/command.mjs +93 -0
- package/src/data.mjs +75 -0
- package/src/diagram-interaction.mjs +195 -0
- package/src/diagram.mjs +135 -0
- package/src/edit-controller.mjs +192 -0
- package/src/edit.mjs +369 -0
- package/src/format.mjs +19 -0
- package/src/history.mjs +14 -0
- package/src/index.mjs +562 -0
- package/src/plugin.mjs +18 -0
- package/src/project-analytics.mjs +357 -0
- package/src/project-linking.mjs +72 -0
- package/src/project.mjs +370 -0
- package/src/recipes.mjs +18 -0
- package/src/renderer.mjs +77 -0
- package/src/scale.mjs +9 -0
- package/src/scene.mjs +18 -0
- package/src/schema.mjs +142 -0
- package/src/spec.mjs +127 -0
- package/src/theme.mjs +232 -0
- package/src/transforms.mjs +34 -0
- package/src/validation.mjs +106 -0
- package/types/index.d.ts +134 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-readable chart capabilities and deterministic Agent planning.
|
|
3
|
+
*/
|
|
4
|
+
import { inspectData } from './data.mjs';
|
|
5
|
+
import { planStyle, styleCapabilities } from './theme.mjs';
|
|
6
|
+
|
|
7
|
+
export const chartTypes = ['line', 'area', 'bar', 'column', 'pie', 'scatter', 'funnel', 'gauge', 'heatmap', 'radar', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane'];
|
|
8
|
+
|
|
9
|
+
const cartesian = ['line', 'area', 'bar', 'column', 'scatter'];
|
|
10
|
+
const project = ['gantt', 'timeline', 'milestone', 'burndown'];
|
|
11
|
+
const diagrams = ['flow', 'swimlane'];
|
|
12
|
+
const status = (supported, notApplicable = []) => Object.fromEntries(supported.map(name => [name, 'supported']).concat(notApplicable.map(name => [name, 'not-applicable'])));
|
|
13
|
+
const commonPresentation = ['title', 'subtitle', 'theme', 'responsive', 'empty-state', 'invalid-data-state', 'export', 'branding'];
|
|
14
|
+
|
|
15
|
+
const definitions = {
|
|
16
|
+
line: { family: 'cartesian', intents: ['trend', 'time-series'], required: ['dimension', 'measure'], optional: ['series'], interactions: ['hover', 'tooltip', 'selection', 'crosshair', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'grid', 'legend', 'labels', 'formatting', 'multi-series', 'dual-axis']) },
|
|
17
|
+
area: { family: 'cartesian', intents: ['trend', 'composition'], required: ['dimension', 'measure'], optional: ['series'], interactions: ['hover', 'tooltip', 'selection', 'crosshair', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'grid', 'legend', 'labels', 'formatting', 'multi-series', 'stacked', 'percent-stacked']) },
|
|
18
|
+
bar: { family: 'cartesian', intents: ['comparison', 'ranking'], required: ['dimension', 'measure'], optional: ['series'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'grid', 'legend', 'labels', 'formatting', 'multi-series', 'stacked', 'percent-stacked']) },
|
|
19
|
+
column: { family: 'cartesian', intents: ['comparison', 'distribution', 'composition'], required: ['dimension', 'measure'], optional: ['series'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'grid', 'legend', 'labels', 'formatting', 'multi-series', 'stacked', 'percent-stacked', 'mixed-line-column', 'dual-axis', 'bin']) },
|
|
20
|
+
pie: { family: 'part-to-whole', intents: ['part-to-whole'], required: ['dimension', 'measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'legend', 'labels', 'formatting', 'donut', 'zero-total'], ['axes', 'grid', 'zoom', 'pan']) },
|
|
21
|
+
scatter: { family: 'cartesian', intents: ['relationship', 'correlation'], required: ['measure', 'measure'], optional: ['dimension'], interactions: ['hover', 'tooltip', 'selection', 'brush', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'grid', 'labels', 'formatting'], ['stacked']) },
|
|
22
|
+
funnel: { family: 'stage', intents: ['funnel', 'conversion'], required: ['dimension', 'measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting'], ['axes', 'grid', 'zoom', 'pan']) },
|
|
23
|
+
gauge: { family: 'indicator', intents: ['progress', 'single-value'], required: ['measure'], optional: ['dimension'], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting', 'domain'], ['axes', 'grid', 'legend', 'zoom', 'pan']) },
|
|
24
|
+
heatmap: { family: 'matrix', intents: ['matrix', 'correlation-grid'], required: ['dimension', 'dimension', 'measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'axes', 'labels', 'formatting', 'color-scale', 'missing-values'], ['zoom', 'pan']) },
|
|
25
|
+
radar: { family: 'radial', intents: ['multidimensional', 'profile'], required: ['three-or-more-measures'], optional: ['series'], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'legend', 'labels', 'formatting', 'multi-series', 'indicator-domains', 'mixed-unit-warning'], ['axes', 'grid', 'zoom', 'pan']) },
|
|
26
|
+
gantt: { family: 'project', intents: ['schedule', 'variance'], required: ['id', 'start', 'end'], optional: ['dependencies', 'progress', 'baseline', 'actual'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard', 'linked-filter', 'linked-selection'], features: status([...commonPresentation, 'labels', 'formatting', 'critical-path', 'slack', 'baseline-actual-variance', 'working-calendar', 'dependencies']) },
|
|
27
|
+
timeline: { family: 'project', intents: ['timeline', 'milestone'], required: ['date', 'label'], optional: ['status'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting']) },
|
|
28
|
+
milestone: { family: 'project', intents: ['milestone'], required: ['date', 'label'], optional: ['baseline', 'actual'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting', 'baseline-actual-variance']) },
|
|
29
|
+
burndown: { family: 'project', intents: ['progress', 'release'], required: ['date', 'remaining'], optional: ['ideal', 'scopeChange'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'labels', 'formatting', 'scope-change', 'release-forecast']) },
|
|
30
|
+
flow: { family: 'diagram', intents: ['workflow'], required: ['nodes'], optional: ['edges', 'groups', 'ports'], interactions: ['hover', 'selection', 'drag', 'keyboard-edit', 'keyboard-port-connect', 'port-connect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'layout', 'groups', 'ports', 'history'], ['axes', 'grid', 'legend']) },
|
|
31
|
+
swimlane: { family: 'diagram', intents: ['responsibility'], required: ['nodes', 'lanes'], optional: ['edges', 'groups', 'ports'], interactions: ['hover', 'selection', 'drag', 'keyboard-edit', 'keyboard-port-connect', 'port-connect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'layout', 'groups', 'ports', 'lanes', 'history'], ['axes', 'grid', 'legend']) }
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const chartProfiles = Object.fromEntries(chartTypes.map(type => [type, {
|
|
35
|
+
type,
|
|
36
|
+
...definitions[type],
|
|
37
|
+
dataShapes: definitions[type].family === 'diagram' ? ['diagram-spec'] : definitions[type].family === 'project' ? ['records'] : ['records', 'data-table'],
|
|
38
|
+
renderers: ['canvas', 'svg'],
|
|
39
|
+
exports: ['png', 'svg', 'json'],
|
|
40
|
+
limits: type === 'pie' ? { recommendedCategories: 8 } : type === 'radar' ? { minimumIndicators: 3, recommendedIndicators: 8 } : type === 'heatmap' ? { recommendedCells: 2500 } : {}
|
|
41
|
+
}]));
|
|
42
|
+
|
|
43
|
+
const intentMap = {
|
|
44
|
+
schedule: 'gantt', variance: 'gantt', timeline: 'timeline', milestone: 'milestone', progress: 'burndown', release: 'burndown', capacity: 'column', risk: 'scatter', aging: 'bar', matrix: 'heatmap', 'correlation-grid': 'heatmap', multidimensional: 'radar', profile: 'radar', workflow: 'flow', responsibility: 'swimlane', trend: 'line', 'time-series': 'line', 'part-to-whole': 'pie', distribution: 'column', relationship: 'scatter', correlation: 'scatter', funnel: 'funnel', conversion: 'funnel', 'single-value': 'gauge', ranking: 'bar', comparison: 'bar', composition: 'column'
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const alternatives = {
|
|
48
|
+
heatmap: ['scatter', 'column'], radar: ['bar', 'line'], gantt: ['timeline', 'milestone'], timeline: ['milestone', 'gantt'], milestone: ['timeline', 'gantt'], burndown: ['line', 'gantt'], column: ['bar', 'line'], scatter: ['bar', 'line'], bar: ['column', 'line'], flow: ['swimlane', 'gantt'], swimlane: ['flow', 'gantt'], line: ['area', 'column'], pie: ['bar', 'column'], funnel: ['bar', 'column'], gauge: ['column', 'bar']
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function warning(code, path, message, suggestion) { return { code, path, message, ...(suggestion ? { suggestion } : {}) }; }
|
|
52
|
+
|
|
53
|
+
export function getChartCapability(type) {
|
|
54
|
+
return chartProfiles[type] ? JSON.parse(JSON.stringify(chartProfiles[type])) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function planChart(input, options = {}) {
|
|
58
|
+
const report = inspectData(input);
|
|
59
|
+
const requestedIntent = options.intent || 'comparison';
|
|
60
|
+
const warnings = [...report.warnings];
|
|
61
|
+
let primary = intentMap[requestedIntent];
|
|
62
|
+
if (!primary) {
|
|
63
|
+
primary = report.fields.some(field => field.type === 'temporal') ? 'line' : report.measures.length >= 2 && report.dimensions.length === 0 ? 'scatter' : 'bar';
|
|
64
|
+
warnings.push(warning('UNKNOWN_INTENT', 'intent', `Intent ${requestedIntent} is not registered.`, 'Use an intent returned by getCapabilities().intents.'));
|
|
65
|
+
}
|
|
66
|
+
const profile = chartProfiles[primary];
|
|
67
|
+
const requiredFields = [];
|
|
68
|
+
const nextActions = [];
|
|
69
|
+
if (!report.rows && !diagrams.includes(primary)) warnings.push(warning('EMPTY_DATA', 'data', 'No rows are available for chart planning.', 'Provide at least one record.'));
|
|
70
|
+
if (['pie', 'funnel'].includes(primary) && report.dimensions.length) {
|
|
71
|
+
const field = report.fields.find(item => item.name === report.dimensions[0]);
|
|
72
|
+
if (field?.cardinality > 8) warnings.push(warning('HIGH_CARDINALITY_PART_TO_WHOLE', `data.${field.name}`, `${field.cardinality} categories may make ${primary} hard to read.`, 'Use bar for comparison or filter to the most important categories.'));
|
|
73
|
+
}
|
|
74
|
+
if (profile.family === 'cartesian' || ['part-to-whole', 'stage'].includes(profile.family)) {
|
|
75
|
+
if (!report.dimensions.length) requiredFields.push('dimension');
|
|
76
|
+
if (!report.measures.length) requiredFields.push('measure');
|
|
77
|
+
}
|
|
78
|
+
if (primary === 'heatmap') {
|
|
79
|
+
if (report.dimensions.length < 2) requiredFields.push('second dimension');
|
|
80
|
+
if (!report.measures.length) requiredFields.push('measure');
|
|
81
|
+
}
|
|
82
|
+
if (primary === 'radar' && report.measures.length < 3) requiredFields.push('three quantitative measures');
|
|
83
|
+
if (requiredFields.length) {
|
|
84
|
+
warnings.push(warning('INSUFFICIENT_FIELDS', 'data', `${primary} requires ${requiredFields.join(' and ')}.`, 'Add the required fields or choose an alternative chart.'));
|
|
85
|
+
nextActions.push(`Provide ${requiredFields.join(' and ')}.`);
|
|
86
|
+
}
|
|
87
|
+
if (warnings.length) nextActions.push('Review warnings before rendering.');
|
|
88
|
+
nextActions.push(`Validate the ${primary} Spec with validateSpec().`);
|
|
89
|
+
const confidence = !report.rows || requiredFields.length ? 0.35 : warnings.some(item => item.code !== 'MISSING_VALUE') ? 0.65 : 0.9;
|
|
90
|
+
const styleRecommendation = planStyle({ type: primary, intent: requestedIntent, data: Array.isArray(input) ? input : input?.values || input, encoding: { x: report.dimensions[0] ? { field: report.dimensions[0] } : undefined, y: report.measures.map(field => ({ field })) } }, options);
|
|
91
|
+
warnings.push(...styleRecommendation.warnings);
|
|
92
|
+
return {
|
|
93
|
+
version: '1.0',
|
|
94
|
+
intent: requestedIntent,
|
|
95
|
+
primary,
|
|
96
|
+
alternatives: alternatives[primary] || ['column', 'line'],
|
|
97
|
+
confidence,
|
|
98
|
+
reasons: [`${primary} supports the ${requestedIntent} intent.`, `Detected ${report.dimensions.length} dimensions and ${report.measures.length} measures across ${report.rows} rows.`],
|
|
99
|
+
requiredFields,
|
|
100
|
+
suggestedEncodings: { dimension: report.dimensions[0] || null, measure: report.measures[0] || null, secondaryMeasure: report.measures[1] || null },
|
|
101
|
+
assumptions: ['Field roles are inferred from provided values; business meaning and units are not inferred.'],
|
|
102
|
+
warnings,
|
|
103
|
+
unsupportedRequests: options.renderer && !profile.renderers.includes(options.renderer) ? [`renderer:${options.renderer}`] : [],
|
|
104
|
+
nextActions,
|
|
105
|
+
capability: getChartCapability(primary),
|
|
106
|
+
styleRecommendation,
|
|
107
|
+
data: report
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function explainChart(spec, model = {}) {
|
|
112
|
+
const profile = chartProfiles[spec.type];
|
|
113
|
+
const encodings = Object.fromEntries(Object.entries(spec.encoding || {}).map(([channel, value]) => [channel, Array.isArray(value) ? value.map(item => item?.field).filter(Boolean) : value?.field]).filter(([, value]) => Array.isArray(value) ? value.length : value));
|
|
114
|
+
const warnings = [...(model.data?.warnings || []), ...(model.state?.projectAnalytics?.warnings || [])];
|
|
115
|
+
return {
|
|
116
|
+
version: '1.0',
|
|
117
|
+
type: spec.type,
|
|
118
|
+
family: profile?.family || 'unknown',
|
|
119
|
+
purpose: profile?.intents?.[0] || 'visualization',
|
|
120
|
+
renderer: spec.renderer,
|
|
121
|
+
dataCount: model.data?.rows?.length || 0,
|
|
122
|
+
encodings,
|
|
123
|
+
transforms: spec.transform ? (Array.isArray(spec.transform) ? spec.transform : [spec.transform]).map(item => item.type) : [],
|
|
124
|
+
interactions: Object.keys(spec.interaction || {}).filter(key => spec.interaction[key]),
|
|
125
|
+
assumptions: [...(model.data?.assumptions || []), ...(model.state?.projectAnalytics?.assumptions || [])],
|
|
126
|
+
warnings,
|
|
127
|
+
style: spec.theme && typeof spec.theme === 'object' ? { name: spec.theme.name, preset: spec.theme.preset, mode: spec.theme.mode, resolvedMode: spec.theme.resolvedMode, palette: spec.theme.palette, reasons: spec.theme.reasons || [], warnings: spec.theme.warnings || [] } : planStyle(spec),
|
|
128
|
+
lineage: { recordIds: (model.data?.rows || []).map((row, index) => String(row.id ?? row.key ?? `record-${index}`)), sourcePreserved: true },
|
|
129
|
+
accessibility: { enabled: Boolean(spec.accessibility?.enabled), summary: spec.accessibility?.description || spec.title?.text || `${spec.type} chart with ${model.data?.rows?.length || 0} data items.` }
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getCapabilities() {
|
|
134
|
+
const intents = [...new Set(Object.values(chartProfiles).flatMap(profile => profile.intents))];
|
|
135
|
+
const exp = {
|
|
136
|
+
types: ['png', 'jpeg', 'svg', 'json'],
|
|
137
|
+
mime: { png: 'image/png', jpeg: 'image/jpeg', svg: 'image/svg+xml', json: 'application/json' },
|
|
138
|
+
browser: { png: true, jpeg: true, svg: true, json: true },
|
|
139
|
+
headless: {
|
|
140
|
+
png: 'optional: install the `canvas` npm package for createCanvas',
|
|
141
|
+
jpeg: 'same as png',
|
|
142
|
+
svg: true,
|
|
143
|
+
json: true,
|
|
144
|
+
},
|
|
145
|
+
methods: {
|
|
146
|
+
exportPNG: 'chart.export({ type:"png" }) returns base64 data URL (browser); pass as:"blob" for a Blob. In Node headless either install the canvas package or fall back to SVG.',
|
|
147
|
+
exportSVG: 'chart.export({ type:"svg" }) returns the SVG string in both browser and headless; as:"dataurl" for embeds, as:"blob" for a Blob (browser).',
|
|
148
|
+
exportJSON: 'chart.export({ type:"json" }) returns pretty JSON; use as:"object" to get the parsed {version,spec,state}.',
|
|
149
|
+
downloadPNG: 'chart.downloadPNG() triggers a browser save-as dialog (filename derived from title + timestamp).',
|
|
150
|
+
downloadSVG: 'chart.downloadSVG() same semantics as downloadPNG but for SVG.',
|
|
151
|
+
downloadJSON: 'chart.downloadJSON() saves {version,spec,state} as a .json document.',
|
|
152
|
+
toDataURL: 'chart.toDataURL("image/png"|"image/svg+xml") returns a data URL string.',
|
|
153
|
+
toBlob: 'chart.toBlob("image/png"|"image/svg+xml") returns a Blob (browser-only).',
|
|
154
|
+
},
|
|
155
|
+
options: { as: ['string', 'object', 'dataurl', 'blob'] },
|
|
156
|
+
branding: 'By default PNG/SVG exports include the Powered by iChart.js branding watermark. Pass branding: { enabled: false } or branding: false to remove it for white-label output.',
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
version: '2.0',
|
|
160
|
+
contractVersion: '1.0',
|
|
161
|
+
chartTypes,
|
|
162
|
+
charts: JSON.parse(JSON.stringify(chartProfiles)),
|
|
163
|
+
intents,
|
|
164
|
+
chartModes: { stack: ['stacked', 'percent'], pie: ['standard', 'donut'], composition: ['multi-series', 'mixed-line-column', 'dual-axis'], transforms: ['bin'] },
|
|
165
|
+
projectManagement: project,
|
|
166
|
+
projectIntelligence: {
|
|
167
|
+
intents: ['schedule', 'milestone', 'progress', 'variance', 'capacity', 'release', 'risk', 'aging', 'workflow', 'responsibility'],
|
|
168
|
+
views: ['gantt', 'burndown', 'column', 'area', 'scatter', 'bar'],
|
|
169
|
+
analytics: ['calendar', 'dependency-normalization', 'critical-path', 'slack', 'baseline-actual-variance', 'capacity', 'cumulative-flow', 'velocity', 'release-forecast', 'risk-matrix', 'issue-aging'],
|
|
170
|
+
linkedState: ['owner', 'status', 'priority', 'sprint', 'label'],
|
|
171
|
+
},
|
|
172
|
+
diagrams,
|
|
173
|
+
excluded: ['map', '3d'],
|
|
174
|
+
renderers: ['canvas', 'svg'],
|
|
175
|
+
interactions: [...new Set(Object.values(chartProfiles).flatMap(profile => profile.interactions))],
|
|
176
|
+
exports: ['png', 'svg', 'json'],
|
|
177
|
+
headless: {
|
|
178
|
+
preview: true,
|
|
179
|
+
json: true,
|
|
180
|
+
svg: true,
|
|
181
|
+
png: 'Node headless PNG requires the optional `canvas` npm package (createCanvas). Otherwise use chart.export({type:"svg"}).',
|
|
182
|
+
},
|
|
183
|
+
export: exp,
|
|
184
|
+
data: ['normalize', 'inspect', 'filter', 'sort', 'groupBy', 'sum', 'average', 'topN', 'percentage', 'bin'],
|
|
185
|
+
themes: [...styleCapabilities.modes],
|
|
186
|
+
styleSystem: JSON.parse(JSON.stringify(styleCapabilities)),
|
|
187
|
+
plugins: true,
|
|
188
|
+
branding: { defaultEnabled: true, signature: 'Powered by iChart.js', options: [{ name: 'enabled', type: 'boolean', default: true }] },
|
|
189
|
+
};
|
|
190
|
+
}
|
package/src/charts.mjs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart-to-scene builders for generic and project visualizations.
|
|
3
|
+
* Keeps chart specifications independent from Canvas and SVG renderers.
|
|
4
|
+
*/
|
|
5
|
+
import { Scene, SceneNode } from './scene.mjs';
|
|
6
|
+
import { normalizeData } from './data.mjs';
|
|
7
|
+
import { applyTransforms } from './transforms.mjs';
|
|
8
|
+
import { buildProjectScene } from './project.mjs';
|
|
9
|
+
import { formatValue } from './format.mjs';
|
|
10
|
+
import { resolveTheme } from './theme.mjs';
|
|
11
|
+
|
|
12
|
+
const pick = (row, encoding, fallback) => row?.[encoding?.field || fallback];
|
|
13
|
+
const font = (spec, role, fallback = '12px system-ui') => spec.theme?.typography?.[role]?.font || fallback;
|
|
14
|
+
|
|
15
|
+
function styledSpec(spec) {
|
|
16
|
+
const theme = spec.theme && typeof spec.theme === 'object' && spec.theme.typography ? spec.theme : resolveTheme(spec.theme, spec);
|
|
17
|
+
return { ...spec, theme, colors: spec.colors || theme.colors, background: spec.background || theme.background, padding: spec.padding || theme.layout.padding };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function layout(spec, data) {
|
|
21
|
+
const p = spec.padding, width = spec.width, height = spec.height;
|
|
22
|
+
const categoryField = spec.encoding.x?.field || 'name';
|
|
23
|
+
const categoryLabelLength = spec.type === 'bar' ? Math.max(0, ...data.rows.map(row => String(row[categoryField] ?? '').length)) : 0;
|
|
24
|
+
const plotLeft = spec.type === 'bar' ? Math.min(width * 0.35, Math.max(p.left, categoryLabelLength * 8 + 24)) : p.left;
|
|
25
|
+
const plotTop = Math.max(p.top, spec.title?.subtitle ? 72 : spec.title?.text ? 56 : p.top);
|
|
26
|
+
const plot = { x: plotLeft, y: plotTop, width: Math.max(1, width - plotLeft - p.right), height: Math.max(1, height - plotTop - p.bottom) };
|
|
27
|
+
const xEncoding = spec.encoding.x || { field: 'name', type: 'category' };
|
|
28
|
+
const xField = xEncoding.field || 'name';
|
|
29
|
+
const yEncodings = Array.isArray(spec.encoding.y) ? spec.encoding.y : [spec.encoding.y || { field: 'value', type: 'quantitative' }];
|
|
30
|
+
const yField = yEncodings[0]?.field || 'value';
|
|
31
|
+
const categories = [...new Set(data.rows.map(row => String(row[xField] ?? '')))].filter(Boolean);
|
|
32
|
+
const stackMode = typeof spec.stack === 'string' ? spec.stack : spec.stack?.mode;
|
|
33
|
+
const stackTotals = stackMode ? data.rows.flatMap(row => {
|
|
34
|
+
const values = yEncodings.map(encoding => Number(row[encoding.field])).filter(Number.isFinite);
|
|
35
|
+
if (stackMode === 'percent') return [0, 1];
|
|
36
|
+
return [values.filter(value => value >= 0).reduce((sum, value) => sum + value, 0), values.filter(value => value < 0).reduce((sum, value) => sum + value, 0)];
|
|
37
|
+
}) : [];
|
|
38
|
+
const numbers = (stackTotals.length ? stackTotals : data.rows.flatMap(row => yEncodings.map(encoding => Number(row[encoding.field])))).filter(Number.isFinite);
|
|
39
|
+
const min = Math.min(0, ...(numbers.length ? numbers : [0]));
|
|
40
|
+
const max = Math.max(1, ...(numbers.length ? numbers : [1]));
|
|
41
|
+
const temporal = xEncoding.type === 'temporal' || xEncoding.type === 'time';
|
|
42
|
+
const quantitativeX = xEncoding.type === 'quantitative' || xEncoding.type === 'linear';
|
|
43
|
+
const dates = temporal ? data.rows.map(row => new Date(row[xField]).getTime()).filter(Number.isFinite) : [];
|
|
44
|
+
const xNumbers = quantitativeX ? data.rows.map(row => Number(row[xField])).filter(Number.isFinite) : [];
|
|
45
|
+
const xMin = dates.length ? Math.min(...dates) : xNumbers.length ? Math.min(...xNumbers) : 0, xMax = dates.length ? Math.max(...dates) : xNumbers.length ? Math.max(...xNumbers) : 1;
|
|
46
|
+
const x = category => temporal ? plot.x + ((new Date(category).getTime() - xMin) / (xMax - xMin || 1)) * plot.width : quantitativeX ? plot.x + ((Number(category) - xMin) / (xMax - xMin || 1)) * plot.width : plot.x + (categories.indexOf(String(category)) + 0.5) * plot.width / Math.max(1, categories.length);
|
|
47
|
+
const axisType = spec.yAxis?.type || yEncodings[0]?.type || 'linear';
|
|
48
|
+
const transform = value => axisType === 'log' ? Math.log10(Math.max(0.000001, Number(value))) : Number(value);
|
|
49
|
+
const transformedMin = transform(min), transformedMax = transform(max);
|
|
50
|
+
const y = value => plot.y + plot.height - ((transform(value) - transformedMin) / (transformedMax - transformedMin || 1)) * plot.height;
|
|
51
|
+
const yRightNumbers = data.rows.map(row => Number(row[yEncodings[1]?.field])).filter(Number.isFinite);
|
|
52
|
+
const rightMin = Math.min(0, ...(yRightNumbers.length ? yRightNumbers : [0])), rightMax = Math.max(1, ...(yRightNumbers.length ? yRightNumbers : [1]));
|
|
53
|
+
const rightType = spec.yAxis?.right?.type || yEncodings[1]?.type || 'linear';
|
|
54
|
+
const rightTransform = value => rightType === 'log' ? Math.log10(Math.max(0.000001, Number(value))) : Number(value);
|
|
55
|
+
const yRight = value => plot.y + plot.height - ((rightTransform(value) - rightTransform(rightMin)) / (rightTransform(rightMax) - rightTransform(rightMin) || 1)) * plot.height;
|
|
56
|
+
return { plot, xField, xEncoding, temporal, quantitativeX, xMin, xMax, yField, yEncodings, categories, min, max, rightMin, rightMax, x, y, yRight, axisType, rightType };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function addText(scene, id, text, x, y, style = {}, dataRef = null) { scene.add(new SceneNode({ id, type: 'text', geometry: { text: String(text), x, y }, style, dataRef })); }
|
|
60
|
+
|
|
61
|
+
function addAxes(scene, spec, state) {
|
|
62
|
+
const { plot, categories, min, max, x, y, yRight } = state;
|
|
63
|
+
scene.add(new SceneNode({ id: 'axis-x', type: 'line', geometry: { x1: plot.x, y1: plot.y + plot.height, x2: plot.x + plot.width, y2: plot.y + plot.height }, style: { stroke: spec.theme.axis } }));
|
|
64
|
+
scene.add(new SceneNode({ id: 'axis-y', type: 'line', geometry: { x1: plot.x, y1: plot.y, x2: plot.x, y2: plot.y + plot.height }, style: { stroke: spec.theme.axis } }));
|
|
65
|
+
if (spec.type === 'bar') {
|
|
66
|
+
const ticks = [min, min + (max - min) / 2, max], numericX = value => plot.x + ((value - min) / (max - min || 1)) * plot.width;
|
|
67
|
+
if (spec.grid?.visible !== false) ticks.forEach((value, index) => scene.add(new SceneNode({ id: `grid-x-${index}`, type: 'line', geometry: { x1: numericX(value), y1: plot.y, x2: numericX(value), y2: plot.y + plot.height }, style: { stroke: spec.grid?.color || spec.theme.grid, strokeWidth: spec.theme.marks.gridWidth }, zIndex: -2 })));
|
|
68
|
+
ticks.forEach(value => addText(scene, `label-x-value-${value}`, formatValue(value, spec.xAxis?.format || spec.yAxis?.format), numericX(value), plot.y + plot.height + 22, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'middle' }));
|
|
69
|
+
categories.forEach((category, index) => addText(scene, `label-y-category-${index}`, category, plot.x - 10, plot.y + (index + .5) * plot.height / Math.max(1, categories.length) + 4, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'end' }));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const labels = state.temporal || state.quantitativeX ? timeTicks(state.xMin, state.xMax, 5) : categories;
|
|
73
|
+
const yTicks = [min, min + (max - min) / 2, max];
|
|
74
|
+
if (spec.grid?.visible !== false) yTicks.forEach((value, index) => scene.add(new SceneNode({ id: `grid-y-${index}`, type: 'line', geometry: { x1: plot.x, y1: y(value), x2: plot.x + plot.width, y2: y(value) }, style: { stroke: spec.grid?.color || spec.theme.grid, strokeWidth: spec.theme.marks.gridWidth }, zIndex: -2 })));
|
|
75
|
+
labels.forEach(category => addText(scene, `label-x-${category}`, state.temporal ? formatTime(category, state.xMax - state.xMin) : state.quantitativeX ? formatValue(category, spec.xAxis?.format) : category, x(category), plot.y + plot.height + 22, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'middle' }));
|
|
76
|
+
yTicks.forEach(value => addText(scene, `label-y-${value}`, formatValue(value, spec.yAxis?.format), plot.x - 10, y(value) + 4, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'end' }));
|
|
77
|
+
if (state.yEncodings.length > 1 && !spec.stack) [state.rightMin, (state.rightMin + state.rightMax) / 2, state.rightMax].forEach(value => addText(scene, `label-y-right-${value}`, formatValue(value, spec.yAxis?.right?.format), plot.x + plot.width + 10, yRight(value) + 4, { fill: spec.theme.muted, font: font(spec, 'axis') }));
|
|
78
|
+
if (spec.xAxis?.title) addText(scene, 'axis-x-title', spec.xAxis.title, plot.x + plot.width / 2, plot.y + plot.height + 42, { fill: spec.theme.text, font: font(spec, 'axis'), textAnchor: 'middle' });
|
|
79
|
+
if (spec.yAxis?.title) addText(scene, 'axis-y-title', spec.yAxis.title, 8, plot.y + 12, { fill: spec.theme.text, font: font(spec, 'axis') });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function addTitles(scene, spec) {
|
|
83
|
+
if (spec.title?.text) addText(scene, 'title', spec.title.text, spec.width / 2, 22, { fill: spec.theme.text, font: font(spec, 'title'), textAnchor: 'middle' });
|
|
84
|
+
if (spec.title?.subtitle) addText(scene, 'subtitle', spec.title.subtitle, spec.width / 2, 40, { fill: spec.theme.muted, font: font(spec, 'subtitle'), textAnchor: 'middle' });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function addLegend(scene, spec, entries) {
|
|
88
|
+
if (spec.legend?.visible === false || entries.length < 2) return;
|
|
89
|
+
const startX = Math.max(12, spec.width - spec.padding.right - entries.length * 92), y = spec.title?.subtitle ? 52 : 38;
|
|
90
|
+
entries.forEach((entry, index) => { const x = startX + index * 92; scene.add(new SceneNode({ id: `legend-swatch-${index}`, type: 'rect', geometry: { x, y: y - 8, width: 10, height: 10 }, style: { fill: entry.color } })); addText(scene, `legend-label-${index}`, entry.name, x + 15, y, { fill: spec.theme.muted, font: font(spec, 'legend') }); });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function addMarkLabel(scene, spec, id, value, x, y, dataRef) {
|
|
94
|
+
if (!spec.labels?.enabled) return;
|
|
95
|
+
addText(scene, `${id}-label`, formatValue(value, spec.labels.format), x, y, { fill: spec.labels.color || spec.theme.text, font: spec.labels.font || font(spec, 'label'), textAnchor: 'middle' }, dataRef);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hexRgb(value) { const hex = String(value || '').replace('#', ''); if (!/^[0-9a-f]{6}$/i.test(hex)) return [37, 99, 235]; return [0, 2, 4].map(index => parseInt(hex.slice(index, index + 2), 16)); }
|
|
99
|
+
function colorMix(start, end, ratio) { const left = hexRgb(start), right = hexRgb(end), value = Math.max(0, Math.min(1, ratio)); return `rgb(${left.map((channel, index) => Math.round(channel + (right[index] - channel) * value)).join(',')})`; }
|
|
100
|
+
|
|
101
|
+
function addHeatmapScene(scene, spec, data, state, colors) {
|
|
102
|
+
const xField = spec.encoding.x?.field || 'x', yField = spec.encoding.y?.field || 'y', valueField = spec.encoding.color?.field || 'value';
|
|
103
|
+
const xValues = [...new Set(data.rows.map(row => String(row[xField])))], yValues = [...new Set(data.rows.map(row => String(row[yField])))];
|
|
104
|
+
const numericValue = row => row[valueField] == null || row[valueField] === '' ? null : Number(row[valueField]);
|
|
105
|
+
const values = data.rows.map(numericValue).filter(Number.isFinite), min = spec.colorScale?.domain?.[0] ?? Math.min(...values, 0), max = spec.colorScale?.domain?.[1] ?? Math.max(...values, 1);
|
|
106
|
+
const palette = spec.theme.palette === 'diverging' ? spec.theme.palettes.diverging : spec.theme.palettes.sequential;
|
|
107
|
+
const low = spec.colorScale?.range?.[0] || palette[0], high = spec.colorScale?.range?.at(-1) || palette.at(-1) || colors[0], missing = spec.colorScale?.missing || spec.theme.palettes.missing;
|
|
108
|
+
const gap = Math.max(0, Number(spec.cellPadding ?? 2)), width = state.plot.width / Math.max(1, xValues.length), height = state.plot.height / Math.max(1, yValues.length);
|
|
109
|
+
xValues.forEach((value, index) => addText(scene, `heatmap-x-${index}`, value, state.plot.x + (index + .5) * width, state.plot.y + state.plot.height + 20, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'middle' }));
|
|
110
|
+
yValues.forEach((value, index) => addText(scene, `heatmap-y-${index}`, value, state.plot.x - 10, state.plot.y + (index + .5) * height + 4, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'end' }));
|
|
111
|
+
data.rows.forEach((row, index) => { const xIndex = xValues.indexOf(String(row[xField])), yIndex = yValues.indexOf(String(row[yField])), value = numericValue(row), geometry = { x: state.plot.x + xIndex * width + gap / 2, y: state.plot.y + yIndex * height + gap / 2, width: Math.max(1, width - gap), height: Math.max(1, height - gap) }; scene.add(new SceneNode({ id: `heatmap-cell-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: Number.isFinite(value) ? colorMix(low, high, (value - min) / (max - min || 1)) : missing, stroke: spec.theme.background, strokeWidth: 1 }, dataRef: { dataIndex: index, x: row[xField], y: row[yField], value: Number.isFinite(value) ? value : null, recordId: row.id || `record-${index}` }, interactive: true })); });
|
|
112
|
+
state.matrix = { xValues, yValues, min, max };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function addRadarScene(scene, spec, data, state, colors) {
|
|
116
|
+
const indicators = spec.indicators, cx = state.plot.x + state.plot.width / 2, cy = state.plot.y + state.plot.height / 2, radius = Math.min(state.plot.width, state.plot.height) * .38;
|
|
117
|
+
if (indicators.some(indicator => !Number.isFinite(Number(indicator.min)) || !Number.isFinite(Number(indicator.max)))) data.warnings.push({ code: 'AMBIGUOUS_RADAR_DOMAIN', message: 'Declare finite min and max for every radar indicator, especially for mixed units.' });
|
|
118
|
+
const point = (indicator, index, ratio = 1) => { const angle = -Math.PI / 2 + index * Math.PI * 2 / indicators.length; return { x: cx + Math.cos(angle) * radius * ratio, y: cy + Math.sin(angle) * radius * ratio }; };
|
|
119
|
+
[0.25, 0.5, 0.75, 1].forEach((ratio, level) => scene.add(new SceneNode({ id: `radar-grid-${level}`, type: 'path', geometry: { points: indicators.map((indicator, index) => point(indicator, index, ratio)), closed: true }, style: { fill: 'none', stroke: spec.theme.grid, strokeWidth: spec.theme.marks.gridWidth } })));
|
|
120
|
+
indicators.forEach((indicator, index) => { const edge = point(indicator, index); scene.add(new SceneNode({ id: `radar-axis-${index}`, type: 'line', geometry: { x1: cx, y1: cy, x2: edge.x, y2: edge.y }, style: { stroke: spec.theme.axis } })); const label = point(indicator, index, 1.14); addText(scene, `radar-label-${index}`, indicator.name || indicator.field, label.x, label.y + 4, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'middle' }); });
|
|
121
|
+
data.rows.forEach((row, seriesIndex) => { const points = indicators.map((indicator, index) => { const min = Number(indicator.min ?? 0), max = Number(indicator.max ?? 1), raw = row[indicator.field], value = raw == null || raw === '' ? NaN : Number(raw); if (!Number.isFinite(value)) data.warnings.push({ code: 'INVALID_RADAR_VALUE', path: `rows.${seriesIndex}.${indicator.field}`, message: 'Radar indicator value must be numeric.' }); return point(indicator, index, Number.isFinite(value) ? Math.max(0, Math.min(1, (value - min) / (max - min || 1))) : 0); }); scene.add(new SceneNode({ id: `radar-series-${seriesIndex}`, type: 'path', geometry: { points, closed: true }, style: { fill: `${colors[seriesIndex % colors.length]}33`, stroke: colors[seriesIndex % colors.length], strokeWidth: 2 }, dataRef: { dataIndex: seriesIndex, recordId: row.id || `record-${seriesIndex}` } })); points.forEach((position, indicatorIndex) => { const dataRef = { dataIndex: seriesIndex, indicatorIndex, field: indicators[indicatorIndex].field, recordId: row.id || `record-${seriesIndex}` }; scene.add(new SceneNode({ id: `radar-item-${seriesIndex}-${indicatorIndex}`, type: 'circle', geometry: { cx: position.x, cy: position.y, r: 4 }, bounds: { x: position.x - 7, y: position.y - 7, width: 14, height: 14 }, style: { fill: colors[seriesIndex % colors.length] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `radar-item-${seriesIndex}-${indicatorIndex}`, row[indicators[indicatorIndex].field], position.x, position.y - 7, dataRef); }); });
|
|
122
|
+
state.indicators = indicators.map(indicator => ({ ...indicator }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function timeTicks(min, max, count) { return Array.from({ length: count }, (_, index) => min + (max - min) * index / Math.max(1, count - 1)); }
|
|
126
|
+
function formatTime(value, span) { const date = new Date(value); if (span > 1000 * 86400000 * 365) return String(date.getUTCFullYear()); if (span > 1000 * 86400000 * 60) return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`; return `${date.getUTCMonth() + 1}/${date.getUTCDate()}`; }
|
|
127
|
+
|
|
128
|
+
function projectRows(spec, data) { return data.rows.length ? data.rows : (spec.tasks || spec.events || []); }
|
|
129
|
+
function projectDate(value, min, max, plot) { const time = new Date(value).getTime(); return plot.x + ((time - min) / (max - min || 1)) * plot.width; }
|
|
130
|
+
function addProjectAxes(scene, plot, min, max) { scene.add(new SceneNode({ id: 'project-axis', type: 'line', geometry: { x1: plot.x, y1: plot.y + plot.height, x2: plot.x + plot.width, y2: plot.y + plot.height }, style: { stroke: '#94a3b8' } })); timeTicks(min, max, 5).forEach(value => addText(scene, `project-label-${value}`, formatTime(value, max - min), projectDate(value, min, max, plot), plot.y + plot.height + 22, { fill: '#475569', font: '12px system-ui', textAnchor: 'middle' })); }
|
|
131
|
+
function addProjectScene(scene, spec, data, state, colors) {
|
|
132
|
+
const { plot } = state;
|
|
133
|
+
if (['gantt', 'timeline', 'milestone'].includes(spec.type)) {
|
|
134
|
+
const rows = projectRows(spec, data), dates = rows.flatMap(row => [new Date(row.start || row.date || row.end).getTime(), new Date(row.end || row.date || row.start).getTime()]).filter(Number.isFinite), min = Math.min(...(dates.length ? dates : [Date.now()])), max = Math.max(...(dates.length ? dates : [min + 86400000]));
|
|
135
|
+
addProjectAxes(scene, plot, min, max);
|
|
136
|
+
const positions = new Map(); rows.forEach((row, index) => { const y = plot.y + (index + 0.5) * plot.height / Math.max(1, rows.length), start = projectDate(row.start || row.date || row.end, min, max, plot), end = projectDate(row.end || row.date || row.start, min, max, plot), x = Math.min(start, end), width = Math.max(8, Math.abs(end - start)); positions.set(row.id, { x, y, end, row }); const milestone = spec.type === 'milestone' || row.milestone || start === end; const geometry = milestone ? { cx: start, cy: y, r: 7 } : { x, y: y - 10, width, height: 20 }; scene.add(new SceneNode({ id: `project-item-${index}`, type: milestone ? 'circle' : 'rect', geometry, bounds: milestone ? { x: start - 10, y: y - 10, width: 20, height: 20 } : geometry, style: { fill: colors[index % colors.length], stroke: '#ffffff', strokeWidth: 1 }, dataRef: { dataIndex: index, taskId: row.id, field: 'project' }, interactive: true })); addText(scene, `project-label-${index}`, row.name || row.title || row.label || row.id || `Item ${index + 1}`, plot.x - 8, y + 4, { fill: '#334155', font: '12px system-ui', textAnchor: 'end' }); if (row.progress != null && !milestone) scene.add(new SceneNode({ id: `project-progress-${index}`, type: 'rect', geometry: { x, y: y - 10, width: width * Math.max(0, Math.min(1, Number(row.progress) > 1 ? Number(row.progress) / 100 : Number(row.progress))), height: 20 }, style: { fill: '#0f172a', opacity: 0.25 }, interactive: false })); });
|
|
137
|
+
const criticalPath = new Set(spec.criticalPath || []);
|
|
138
|
+
rows.forEach((row, index) => (row.dependencies || []).forEach((dependency, dependencyIndex) => { const from = positions.get(dependency), to = positions.get(row.id); if (!from || !to) return; const critical = criticalPath.has(row.id) && criticalPath.has(dependency); const angle = Math.atan2(to.y - from.y, to.x - from.end), arrow = 7; scene.add(new SceneNode({ id: `dependency-${index}-${dependencyIndex}`, type: 'line', geometry: { x1: from.end, y1: from.y, x2: to.x, y2: to.y }, style: { stroke: critical ? '#dc2626' : '#64748b', strokeWidth: critical ? 2.5 : 1.5, opacity: 0.85 }, dataRef: { from: dependency, to: row.id, critical }, interactive: false, zIndex: -1 })); scene.add(new SceneNode({ id: `dependency-arrow-${index}-${dependencyIndex}`, type: 'path', geometry: { points: [{ x: to.x, y: to.y }, { x: to.x - arrow * Math.cos(angle - Math.PI / 6), y: to.y - arrow * Math.sin(angle - Math.PI / 6) }, { x: to.x - arrow * Math.cos(angle + Math.PI / 6), y: to.y - arrow * Math.sin(angle + Math.PI / 6) }] }, style: { stroke: critical ? '#dc2626' : '#64748b', fill: critical ? '#dc2626' : '#64748b', strokeWidth: 1 }, interactive: false, zIndex: -1 })); }));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (spec.type === 'burndown') {
|
|
142
|
+
const rows = projectRows(spec, data), dates = rows.map(row => new Date(row.date).getTime()).filter(Number.isFinite), min = Math.min(...(dates.length ? dates : [Date.now()])), max = Math.max(...(dates.length ? dates : [min + 86400000])), values = rows.map(row => Number(row.remaining ?? row.actual ?? row.value)).filter(Number.isFinite), maxValue = Math.max(1, ...values, ...rows.map(row => Number(row.ideal)).filter(Number.isFinite)); addProjectAxes(scene, plot, min, max); const point = row => ({ x: projectDate(row.date, min, max, plot), y: plot.y + plot.height - (Number(row.remaining ?? row.actual ?? row.value) / maxValue) * plot.height }); const actual = rows.map(point).filter(point => Number.isFinite(point.y)); if (actual.length > 1) scene.add(new SceneNode({ id: 'burndown-actual', type: 'path', geometry: { points: actual }, style: { stroke: colors[0], strokeWidth: 2 } })); const ideal = rows.map((row, index) => ({ x: projectDate(row.date, min, max, plot), y: plot.y + plot.height - (Number(row.ideal ?? maxValue * (1 - index / Math.max(1, rows.length - 1))) / maxValue) * plot.height })); if (ideal.length > 1) scene.add(new SceneNode({ id: 'burndown-ideal', type: 'path', geometry: { points: ideal }, style: { stroke: '#94a3b8', strokeWidth: 1, opacity: 0.8 } })); rows.forEach((row, index) => { if (index && Number(row.scopeChange)) { const x = projectDate(row.date, min, max, plot); scene.add(new SceneNode({ id: `burndown-scope-${index}`, type: 'line', geometry: { x1: x, y1: plot.y, x2: x, y2: plot.y + plot.height }, style: { stroke: '#f59e0b', strokeWidth: 1.5, opacity: 0.8 }, dataRef: { dataIndex: index, field: 'scopeChange', datum: row } })); } }); actual.forEach((point, index) => scene.add(new SceneNode({ id: `burndown-item-${index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 7, y: point.y - 7, width: 14, height: 14 }, style: { fill: colors[0] }, dataRef: { dataIndex: index, field: 'remaining', datum: rows[index] }, interactive: true }))); const last = actual.at(-1), lastRow = rows.at(-1); if (last && lastRow && Number(lastRow.remaining ?? lastRow.actual ?? lastRow.value) > 0 && rows.length > 1) { const slope = (Number(lastRow.remaining ?? lastRow.actual ?? lastRow.value) - Number(rows[0].remaining ?? rows[0].actual ?? rows[0].value)) / Math.max(1, dates.length - 1), projected = slope < 0 ? new Date(new Date(lastRow.date).getTime() + Math.ceil(Number(lastRow.remaining ?? lastRow.actual ?? lastRow.value) / -slope) * 86400000) : null; if (projected) addText(scene, 'burndown-projected', `Projected ${formatTime(projected, max - min)}`, Math.min(plot.x + plot.width, last.x + 12), plot.y + 18, { fill: '#dc2626', font: '12px system-ui' }); } return;
|
|
143
|
+
}
|
|
144
|
+
if (['flow', 'swimlane'].includes(spec.type)) {
|
|
145
|
+
const nodes = spec.nodes || spec.data.nodes || [], edges = spec.edges || spec.data.edges || [], lanes = spec.lanes || spec.data.lanes || [], laneHeight = plot.height / Math.max(1, lanes.length || 1); const nodeMap = new Map(); nodes.forEach((node, index) => { const laneIndex = lanes.length ? Math.max(0, lanes.findIndex(lane => lane.id === node.laneId)) : 0, columns = Math.max(1, Math.ceil(Math.sqrt(nodes.length))), column = index % columns, row = Math.floor(index / columns), x = plot.x + (column + 0.5) * plot.width / columns, y = lanes.length ? plot.y + laneIndex * laneHeight + laneHeight / 2 : plot.y + (row + 0.5) * plot.height / Math.max(1, Math.ceil(nodes.length / columns)); nodeMap.set(node.id, { ...node, x, y }); if (lanes.length) { scene.add(new SceneNode({ id: `lane-${laneIndex}`, type: 'rect', geometry: { x: plot.x, y: plot.y + laneIndex * laneHeight, width: plot.width, height: laneHeight }, style: { fill: laneIndex % 2 ? '#f8fafc' : '#ffffff', stroke: '#e2e8f0', strokeWidth: 1 }, interactive: false })); addText(scene, `lane-label-${laneIndex}`, lanes[laneIndex].label || lanes[laneIndex].id, plot.x - 8, plot.y + laneIndex * laneHeight + 18, { fill: '#475569', font: '12px system-ui', textAnchor: 'end' }); } }); edges.forEach((edge, index) => { const from = nodeMap.get(edge.from), to = nodeMap.get(edge.to); if (!from || !to) return; scene.add(new SceneNode({ id: `edge-${index}`, type: 'line', geometry: { x1: from.x, y1: from.y, x2: to.x, y2: to.y }, style: { stroke: '#64748b', strokeWidth: 1.5 }, dataRef: { edgeIndex: index, from: edge.from, to: edge.to } })); }); nodeMap.forEach((node, id) => { const geometry = { x: node.x - 48, y: node.y - 18, width: 96, height: 36 }; scene.add(new SceneNode({ id: `node-${id}`, type: 'rect', geometry, bounds: geometry, style: { fill: colors[0], stroke: '#ffffff', strokeWidth: 1 }, dataRef: { nodeId: id, field: 'node' }, interactive: true })); addText(scene, `node-label-${id}`, node.label || id, node.x, node.y + 4, { fill: '#ffffff', font: '12px system-ui', textAnchor: 'middle' }); });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function addBranding(scene, spec) {
|
|
150
|
+
if (spec.branding?.enabled !== true) return;
|
|
151
|
+
const x = Math.max(12, Number(spec.width) - 4);
|
|
152
|
+
const y = Math.max(12, Number(spec.height) - 4);
|
|
153
|
+
const fontFamily = spec.theme?.typography?.axis?.font ? `500 9px ${String(spec.theme.typography.axis.font).split(/px\s*/).slice(1).join('px ').trim() || 'system-ui'}` : '500 9px system-ui, sans-serif';
|
|
154
|
+
scene.add(new SceneNode({
|
|
155
|
+
id: 'branding-watermark',
|
|
156
|
+
type: 'text',
|
|
157
|
+
geometry: { text: 'Powered by iChart.js', x, y },
|
|
158
|
+
style: {
|
|
159
|
+
fill: spec.theme?.muted || '#536274',
|
|
160
|
+
opacity: 0.35,
|
|
161
|
+
font: fontFamily,
|
|
162
|
+
textAnchor: 'end',
|
|
163
|
+
textAlign: 'end',
|
|
164
|
+
textBaseline: 'bottom',
|
|
165
|
+
baseline: 'bottom',
|
|
166
|
+
pointerEvents: 'none',
|
|
167
|
+
ariaHidden: 'true',
|
|
168
|
+
role: 'presentation'
|
|
169
|
+
},
|
|
170
|
+
interactive: false,
|
|
171
|
+
zIndex: 9999,
|
|
172
|
+
decorative: true
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function buildScene(spec) {
|
|
177
|
+
const baseSpec = styledSpec(spec);
|
|
178
|
+
const brandingEnabled = baseSpec.branding?.enabled === true;
|
|
179
|
+
const effectivePadding = {
|
|
180
|
+
top: Number(baseSpec.padding?.top ?? 48),
|
|
181
|
+
right: Number(baseSpec.padding?.right ?? 24) + (brandingEnabled ? 2 : 0),
|
|
182
|
+
bottom: Number(baseSpec.padding?.bottom ?? 48) + (brandingEnabled ? 18 : 0),
|
|
183
|
+
left: Number(baseSpec.padding?.left ?? 56),
|
|
184
|
+
};
|
|
185
|
+
spec = { ...baseSpec, padding: effectivePadding };
|
|
186
|
+
const isProjectFamily = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane'].includes(spec.type);
|
|
187
|
+
if (isProjectFamily) {
|
|
188
|
+
const result = buildProjectScene(spec);
|
|
189
|
+
addBranding(result.scene, spec);
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
const data = spec.transform ? applyTransforms(spec.data, spec.transform) : normalizeData(spec.data);
|
|
193
|
+
if (spec.view && (spec.view.start !== undefined || spec.view.end !== undefined)) {
|
|
194
|
+
const start = Math.max(0, Number(spec.view.start) || 0), end = Math.min(data.rows.length, spec.view.end === undefined ? data.rows.length : Number(spec.view.end));
|
|
195
|
+
data.rows = data.rows.slice(start, Math.max(start, end));
|
|
196
|
+
data.viewOffset = start;
|
|
197
|
+
}
|
|
198
|
+
const scene = new Scene(spec.width, spec.height);
|
|
199
|
+
const state = layout(spec, data);
|
|
200
|
+
const colors = spec.colors;
|
|
201
|
+
addTitles(scene, spec);
|
|
202
|
+
if (!['pie', 'funnel', 'gauge', 'heatmap', 'radar'].includes(spec.type)) addAxes(scene, spec, state);
|
|
203
|
+
const hasDiagramNodes = ['flow', 'swimlane'].includes(spec.type) && (spec.nodes || spec.data.nodes)?.length;
|
|
204
|
+
if (!data.rows.length && !hasDiagramNodes) { addText(scene, 'empty', spec.emptyText || 'No data', spec.width / 2, spec.height / 2, { fill: spec.theme.muted, font: font(spec, 'subtitle'), textAnchor: 'middle' }); addBranding(scene, spec); return { scene, data, state }; }
|
|
205
|
+
const yField = state.yField;
|
|
206
|
+
const series = Array.isArray(spec.encoding.y) ? spec.encoding.y : [{ ...spec.encoding.y, name: spec.encoding.y?.name || spec.encoding.y?.field }];
|
|
207
|
+
if (spec.type === 'heatmap') addHeatmapScene(scene, spec, data, state, colors);
|
|
208
|
+
else if (spec.type === 'radar') { addRadarScene(scene, spec, data, state, colors); addLegend(scene, spec, data.rows.map((row, index) => ({ name: row.name || row.id || `Series ${index + 1}`, color: colors[index % colors.length] }))); }
|
|
209
|
+
else if (spec.type === 'line' || spec.type === 'area') {
|
|
210
|
+
const mode = typeof spec.stack === 'string' ? spec.stack : spec.stack?.mode, accumulators = data.rows.map(() => ({ positive: 0, negative: 0 }));
|
|
211
|
+
series.forEach((encoding, seriesIndex) => { const yMap = seriesIndex === 1 && !mode ? state.yRight : state.y, values = data.rows.map(row => Number(row[encoding.field])), totals = data.rows.map((row, index) => series.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || Math.abs(values[index]) || 1); const points = [], bases = []; data.rows.forEach((row, index) => { let value = values[index]; if (!Number.isFinite(value)) return; if (mode === 'percent') value /= totals[index]; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = mode ? start + value : value; if (mode) accumulators[index][accumulator] = end; points.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(end), row, index, value }); bases.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(start) }); }); if (spec.type === 'area' && points.length) scene.add(new SceneNode({ id: `area-fill-${seriesIndex}`, type: 'path', geometry: { points: [...bases.slice().reverse(), ...points.map(point => ({ x: point.x, y: point.y }))], closed: true }, style: { fill: `${colors[seriesIndex]}33`, stroke: 'transparent' } })); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points }, style: { stroke: colors[seriesIndex], strokeWidth: 2, fill: 'none' } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 5 }, bounds: { x: point.x - 8, y: point.y - 8, width: 16, height: 16 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.y - 8, dataRef); }); });
|
|
212
|
+
addLegend(scene, spec, series.map((encoding, index) => ({ name: encoding.name || encoding.field, color: colors[index % colors.length] })));
|
|
213
|
+
} else if (spec.type === 'bar' || spec.type === 'column') {
|
|
214
|
+
const band = state.plot.width / Math.max(1, state.categories.length), bar = band * 0.58;
|
|
215
|
+
const mode = typeof spec.stack === 'string' ? spec.stack : spec.stack?.mode, accumulators = data.rows.map(() => ({ positive: 0, negative: 0 }));
|
|
216
|
+
series.forEach((encoding, seriesIndex) => { if (encoding.mark === 'line') { const yMap = encoding.axis === 'right' ? state.yRight : state.y, points = data.rows.map((row, index) => ({ x: state.x(row[state.xField]), y: yMap(row[encoding.field]), index, value: Number(row[encoding.field]) })).filter(point => Number.isFinite(point.y)); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points }, style: { fill: 'none', stroke: colors[seriesIndex], strokeWidth: 2 } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 7, y: point.y - 7, width: 14, height: 14 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.y - 7, dataRef); }); return; } data.rows.forEach((row, index) => { let value = Number(row[encoding.field]) || 0; const barSeries = series.filter(item => item.mark !== 'line'), barSeriesIndex = barSeries.indexOf(encoding), total = barSeries.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || 1; if (mode === 'percent') value /= total; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = start + value; if (mode) accumulators[index][accumulator] = end; const yMap = encoding.axis === 'right' ? state.yRight : state.y, base = yMap(start), valueY = yMap(end); const vertical = spec.type === 'column'; const horizontalStart = state.plot.x + ((start - state.min) / (state.max - state.min || 1)) * state.plot.width, horizontalEnd = state.plot.x + ((end - state.min) / (state.max - state.min || 1)) * state.plot.width, rowBand = state.plot.height / Math.max(1, data.rows.length), totalHeight = rowBand * .62, itemHeight = mode ? totalHeight : totalHeight / Math.max(1, barSeries.length); const geometry = vertical ? { x: state.x(row[state.xField]) - bar / 2 + (mode ? 0 : seriesIndex * bar / Math.max(1, series.length)), y: Math.min(base, valueY), width: mode ? bar : bar / Math.max(1, series.length), height: Math.abs(base - valueY) } : { x: Math.min(horizontalStart, horizontalEnd), y: state.plot.y + index * rowBand + (rowBand - totalHeight) / 2 + (mode ? 0 : barSeriesIndex * itemHeight), width: Math.abs(horizontalEnd - horizontalStart), height: itemHeight }; const dataRef = { seriesIndex, dataIndex: index, field: encoding.field, stackStart: start, stackEnd: end }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${index}`, value, vertical ? geometry.x + geometry.width / 2 : geometry.x + geometry.width + 12, vertical ? geometry.y - 5 : geometry.y + geometry.height / 2 + 4, dataRef); }); });
|
|
217
|
+
addLegend(scene, spec, series.map((encoding, index) => ({ name: encoding.name || encoding.field, color: colors[index % colors.length] })));
|
|
218
|
+
} else if (spec.type === 'scatter') {
|
|
219
|
+
data.rows.forEach((row, index) => { const xValue = Number(row[state.xField]), yValue = Number(row[state.yField]); if (!Number.isFinite(xValue) || !Number.isFinite(yValue)) return; const geometry = { cx: state.x(xValue), cy: state.y(yValue), r: 5 }, dataRef = { seriesIndex: 0, dataIndex: index, field: state.yField }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'circle', geometry, bounds: { x: geometry.cx - 8, y: geometry.cy - 8, width: 16, height: 16 }, style: { fill: colors[0] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, yValue, geometry.cx, geometry.cy - 8, dataRef); });
|
|
220
|
+
} else if (spec.type === 'pie') {
|
|
221
|
+
const categoryField = spec.encoding.category?.field || 'name', valueField = spec.encoding.value?.field || 'value', total = data.rows.reduce((sum, row) => sum + Math.max(0, Number(row[valueField]) || 0), 0), cx = spec.width / 2, cy = spec.height / 2 + 12, radius = Math.min(spec.width, spec.height) * 0.28, innerR = radius * Number(spec.innerRadius || 0); let angle = -Math.PI / 2;
|
|
222
|
+
if (!(total > 0)) { data.warnings.push({ code: 'ZERO_TOTAL', path: `encoding.value.${valueField}`, message: 'Pie requires a positive total.' }); addText(scene, 'pie-zero-total', spec.emptyText || 'No positive values', cx, cy, { fill: spec.theme.muted, font: font(spec, 'subtitle'), textAnchor: 'middle' }); }
|
|
223
|
+
else data.rows.forEach((row, index) => { const value = Math.max(0, Number(row[valueField]) || 0), end = angle + value / total * Math.PI * 2, middle = angle + (end - angle) / 2, dataRef = { seriesIndex: 0, dataIndex: index, category: row[categoryField], value }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'arc', geometry: { cx, cy, r: radius, innerR, start: angle, end }, bounds: { x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2 }, style: { fill: colors[index % colors.length], stroke: spec.theme.background, strokeWidth: 1 }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value / total, cx + Math.cos(middle) * radius * .72, cy + Math.sin(middle) * radius * .72, dataRef); angle = end; });
|
|
224
|
+
addLegend(scene, spec, data.rows.map((row, index) => ({ name: String(row[categoryField]), color: colors[index % colors.length] })));
|
|
225
|
+
} else if (spec.type === 'funnel') {
|
|
226
|
+
const valueField = spec.encoding.value?.field || 'value'; const maxValue = Math.max(...data.rows.map(row => Number(row[valueField]) || 0), 1); const segmentHeight = state.plot.height / data.rows.length;
|
|
227
|
+
data.rows.forEach((row, index) => { const value = Number(row[valueField]) || 0, ratio = Math.max(0.1, value / maxValue); const width = state.plot.width * ratio; const geometry = { x: state.plot.x + (state.plot.width - width) / 2, y: state.plot.y + index * segmentHeight, width, height: Math.max(2, segmentHeight - 3) }, dataRef = { seriesIndex: 0, dataIndex: index, field: valueField }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: colors[index % colors.length] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value, spec.width / 2, geometry.y + geometry.height / 2 + 4, dataRef); });
|
|
228
|
+
} else if (spec.type === 'gauge') {
|
|
229
|
+
const valueField = spec.encoding.value?.field || 'value', domain = spec.domain || [0, 100], rawValue = Number(data.rows[0]?.[valueField]) || 0, value = Math.max(Number(domain[0]), Math.min(Number(domain[1]), rawValue)), ratio = (value - Number(domain[0])) / (Number(domain[1]) - Number(domain[0]) || 1), cx = spec.width / 2, cy = spec.height * 0.62, radius = Math.min(spec.width, spec.height) * 0.3, start = Math.PI, end = start + Math.PI * ratio;
|
|
230
|
+
scene.add(new SceneNode({ id: 'gauge-background', type: 'arc', geometry: { cx, cy, r: radius, start: Math.PI, end: Math.PI * 2 }, style: { fill: spec.theme.grid } })); scene.add(new SceneNode({ id: 'gauge-value', type: 'arc', geometry: { cx, cy, r: radius, start, end }, style: { fill: colors[0] }, dataRef: { seriesIndex: 0, dataIndex: 0, value }, interactive: true })); addText(scene, 'gauge-label', formatValue(value, spec.labels?.format || spec.encoding.value?.format || { style: 'percent', ratio: false }), cx, cy - 12, { fill: spec.theme.text, font: font(spec, 'metric'), textAnchor: 'middle' });
|
|
231
|
+
}
|
|
232
|
+
addBranding(scene, spec);
|
|
233
|
+
return { scene, data, state };
|
|
234
|
+
}
|
package/src/command.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent edit command normalization, validation, and capability discovery.
|
|
3
|
+
* Commands are JSON-safe, stable-ID based, and do not mutate input data.
|
|
4
|
+
*/
|
|
5
|
+
import { copyJSON, issue } from './schema.mjs';
|
|
6
|
+
|
|
7
|
+
export const commandVersion = '1.0';
|
|
8
|
+
export const operationTypes = ['updateField', 'updateRecord', 'updateTask', 'shiftTask', 'updateProgress', 'addDependency', 'removeDependency', 'updateMilestone', 'moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'toggleGroupCollapse', 'addEdge', 'duplicateSelection', 'pasteSelection'];
|
|
9
|
+
|
|
10
|
+
export function normalizeCommand(input) {
|
|
11
|
+
const command = copyJSON(input);
|
|
12
|
+
if (!command.version) command.version = commandVersion;
|
|
13
|
+
if (!command.type) command.type = 'edit';
|
|
14
|
+
if (!Array.isArray(command.operations)) command.operations = [];
|
|
15
|
+
return command;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateCommand(command) {
|
|
19
|
+
const errors = [];
|
|
20
|
+
let value;
|
|
21
|
+
try { value = normalizeCommand(command); } catch (error) { return { valid: false, errors: [issue('INVALID_COMMAND', 'command', error.message)], command: null }; }
|
|
22
|
+
if (value.version !== commandVersion) errors.push(issue('COMMAND_VERSION', 'command.version', `Unsupported command version: ${value.version}.`, 'Use command version 1.0.'));
|
|
23
|
+
if (value.type !== 'edit' && value.type !== 'layout-edit') errors.push(issue('COMMAND_TYPE', 'command.type', 'Command type must be edit or layout-edit.'));
|
|
24
|
+
if (!Array.isArray(value.operations) || !value.operations.length) errors.push(issue('EMPTY_COMMAND', 'command.operations', 'At least one operation is required.'));
|
|
25
|
+
value.operations.forEach((operation, index) => {
|
|
26
|
+
const path = `command.operations.${index}`;
|
|
27
|
+
if (!operation || typeof operation.op !== 'string' || !operationTypes.includes(operation.op)) errors.push(issue('OPERATION_TYPE', `${path}.op`, 'Unsupported edit operation.'));
|
|
28
|
+
if (operationTypes.includes(operation.op) && !['moveNodes', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection', 'addEdge'].includes(operation.op) && (typeof operation.recordId !== 'string' && typeof operation.taskId !== 'string' && typeof operation.nodeId !== 'string' && typeof operation.edgeId !== 'string')) errors.push(issue('OPERATION_TARGET', path, 'Operation requires a stable recordId, taskId, nodeId, or edgeId.'));
|
|
29
|
+
if (['updateField', 'updateRecord', 'updateTask'].includes(operation.op) && !operation.changes && operation.op !== 'updateField') errors.push(issue('OPERATION_CHANGES', `${path}.changes`, 'Operation requires changes.'));
|
|
30
|
+
if (operation.op === 'updateField' && typeof operation.field !== 'string') errors.push(issue('OPERATION_FIELD', `${path}.field`, 'updateField requires a field name.'));
|
|
31
|
+
if (operation.op === 'shiftTask' && (typeof operation.days !== 'number' || !Number.isFinite(operation.days))) errors.push(issue('OPERATION_DAYS', `${path}.days`, 'shiftTask requires finite numeric days.'));
|
|
32
|
+
if (['addDependency', 'removeDependency'].includes(operation.op) && typeof operation.dependencyId !== 'string') errors.push(issue('OPERATION_DEPENDENCY', `${path}.dependencyId`, 'Dependency operations require dependencyId.'));
|
|
33
|
+
if (operation.op === 'moveNode' && (!operation.position || typeof operation.position.x !== 'number' || typeof operation.position.y !== 'number')) errors.push(issue('OPERATION_POSITION', `${path}.position`, 'moveNode requires numeric x and y.'));
|
|
34
|
+
if (operation.op === 'moveNodes' && (!Array.isArray(operation.nodeIds) || !operation.nodeIds.length || typeof operation.delta?.x !== 'number' || typeof operation.delta?.y !== 'number')) errors.push(issue('OPERATION_MOVE_NODES', path, 'moveNodes requires nodeIds and numeric delta.'));
|
|
35
|
+
if (operation.op === 'moveGroup' && (typeof operation.groupId !== 'string' || typeof operation.delta?.x !== 'number' || typeof operation.delta?.y !== 'number')) errors.push(issue('OPERATION_MOVE_GROUP', path, 'moveGroup requires groupId and numeric delta.'));
|
|
36
|
+
if (operation.op === 'resizeGroup' && (typeof operation.groupId !== 'string' || typeof operation.size?.width !== 'number' || typeof operation.size?.height !== 'number' || operation.size.width <= 0 || operation.size.height <= 0)) errors.push(issue('OPERATION_RESIZE_GROUP', path, 'resizeGroup requires groupId and a positive numeric size.'));
|
|
37
|
+
if (operation.op === 'assignNodesToGroup' && ((!Array.isArray(operation.nodeIds) || !operation.nodeIds.length) || operation.groupId !== null && typeof operation.groupId !== 'string')) errors.push(issue('OPERATION_GROUP_MEMBERS', path, 'assignNodesToGroup requires nodeIds and a groupId or null.'));
|
|
38
|
+
if (operation.op === 'duplicateGroup' && (typeof operation.groupId !== 'string' || operation.offset !== undefined && (typeof operation.offset?.x !== 'number' || typeof operation.offset?.y !== 'number'))) errors.push(issue('OPERATION_DUPLICATE_GROUP', path, 'duplicateGroup requires groupId and an optional numeric offset.'));
|
|
39
|
+
if (operation.op === 'deleteGroup' && (typeof operation.groupId !== 'string' || operation.policy !== undefined && !['ungroup', 'delete-members'].includes(operation.policy))) errors.push(issue('OPERATION_DELETE_GROUP', path, 'deleteGroup requires groupId and policy ungroup or delete-members.'));
|
|
40
|
+
if (['alignNodes', 'snapNodes'].includes(operation.op) && (!Array.isArray(operation.nodeIds) || !operation.nodeIds.length)) errors.push(issue('OPERATION_NODE_IDS', path, `${operation.op} requires nodeIds.`));
|
|
41
|
+
if (operation.op === 'alignNodes' && !['left', 'center', 'right', 'top', 'middle', 'bottom'].includes(operation.alignment)) errors.push(issue('OPERATION_ALIGNMENT', `${path}.alignment`, 'Use left, center, right, top, middle, or bottom.'));
|
|
42
|
+
if (operation.op === 'resizeNode' && (!operation.size || typeof operation.size.width !== 'number' || typeof operation.size.height !== 'number' || operation.size.width <= 0 || operation.size.height <= 0)) errors.push(issue('OPERATION_SIZE', `${path}.size`, 'resizeNode requires positive numeric width and height.'));
|
|
43
|
+
if (operation.op === 'moveNodeToLane' && typeof operation.laneId !== 'string') errors.push(issue('OPERATION_LANE', `${path}.laneId`, 'moveNodeToLane requires laneId.'));
|
|
44
|
+
if (operation.op === 'updateEdge' && (!operation.changes || typeof operation.changes !== 'object')) errors.push(issue('OPERATION_CHANGES', `${path}.changes`, 'updateEdge requires changes.'));
|
|
45
|
+
if (operation.op === 'toggleGroupCollapse' && typeof operation.groupId !== 'string') errors.push(issue('OPERATION_GROUP', `${path}.groupId`, 'toggleGroupCollapse requires groupId.'));
|
|
46
|
+
if (operation.op === 'addEdge') {
|
|
47
|
+
if (typeof operation.from !== 'string' || typeof operation.to !== 'string') errors.push(issue('OPERATION_EDGE_ENDPOINT', path, 'addEdge requires from and to node IDs.'));
|
|
48
|
+
if (operation.id !== undefined && typeof operation.id !== 'string') errors.push(issue('OPERATION_EDGE_ID', `${path}.id`, 'Edge id must be a string when provided.'));
|
|
49
|
+
if (operation.fromPort !== undefined && typeof operation.fromPort !== 'string') errors.push(issue('OPERATION_EDGE_PORT', `${path}.fromPort`, 'fromPort must be a string.'));
|
|
50
|
+
if (operation.toPort !== undefined && typeof operation.toPort !== 'string') errors.push(issue('OPERATION_EDGE_PORT', `${path}.toPort`, 'toPort must be a string.'));
|
|
51
|
+
}
|
|
52
|
+
if (operation.op === 'duplicateSelection' && (!Array.isArray(operation.nodeIds) || !operation.nodeIds.length)) errors.push(issue('OPERATION_NODE_IDS', path, 'duplicateSelection requires nodeIds.'));
|
|
53
|
+
if (operation.op === 'pasteSelection') {
|
|
54
|
+
if (!Array.isArray(operation.nodes) || !operation.nodes.length) errors.push(issue('OPERATION_CLIPBOARD', `${path}.nodes`, 'pasteSelection requires copied nodes.'));
|
|
55
|
+
if (operation.edges !== undefined && !Array.isArray(operation.edges)) errors.push(issue('OPERATION_CLIPBOARD', `${path}.edges`, 'pasteSelection edges must be an array when provided.'));
|
|
56
|
+
if (operation.offset !== undefined && (typeof operation.offset?.x !== 'number' || typeof operation.offset?.y !== 'number')) errors.push(issue('OPERATION_POSITION', `${path}.offset`, 'pasteSelection offset requires numeric x and y.'));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return { valid: errors.length === 0, errors, command: value };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function commandCapabilities(schema) {
|
|
63
|
+
const fields = schema?.fields || {};
|
|
64
|
+
const can = name => fields[name]?.editable !== false && fields[name]?.agentEditable !== false;
|
|
65
|
+
const flowNode = schema?.name === 'flow-node';
|
|
66
|
+
const flowEdge = schema?.name === 'flow-edge';
|
|
67
|
+
return {
|
|
68
|
+
updateField: Object.keys(fields).filter(can),
|
|
69
|
+
updateRecord: true,
|
|
70
|
+
updateTask: schema?.name === 'project-task',
|
|
71
|
+
shiftTask: schema?.name === 'project-task' && can('start') && can('end'),
|
|
72
|
+
updateProgress: schema?.name === 'project-task' && can('progress'),
|
|
73
|
+
addDependency: schema?.name === 'project-task' && can('dependencies'),
|
|
74
|
+
removeDependency: schema?.name === 'project-task' && can('dependencies'),
|
|
75
|
+
updateMilestone: schema?.name === 'milestone',
|
|
76
|
+
moveNode: flowNode && can('position'),
|
|
77
|
+
moveNodes: flowNode && can('position'),
|
|
78
|
+
alignNodes: flowNode && can('position'),
|
|
79
|
+
snapNodes: flowNode && can('position'),
|
|
80
|
+
moveGroup: flowNode && can('position'),
|
|
81
|
+
resizeGroup: flowNode && can('position') && can('size'),
|
|
82
|
+
assignNodesToGroup: flowNode,
|
|
83
|
+
duplicateGroup: flowNode,
|
|
84
|
+
deleteGroup: flowNode,
|
|
85
|
+
moveNodeToLane: flowNode && can('laneId'),
|
|
86
|
+
resizeNode: flowNode && can('size'),
|
|
87
|
+
toggleGroupCollapse: flowNode,
|
|
88
|
+
duplicateSelection: flowNode,
|
|
89
|
+
pasteSelection: flowNode,
|
|
90
|
+
updateEdge: flowEdge,
|
|
91
|
+
addEdge: flowEdge
|
|
92
|
+
};
|
|
93
|
+
}
|