@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.
Files changed (80) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +201 -0
  3. package/README.md +194 -0
  4. package/agent-recipes/diagrams/agent-orchestration.json +18 -0
  5. package/agent-recipes/diagrams/approval-process.json +16 -0
  6. package/agent-recipes/diagrams/responsibility-mapping.json +13 -0
  7. package/agent-recipes/diagrams/workflow.json +19 -0
  8. package/agent-recipes/foundational-analysis.json +18 -0
  9. package/agent-recipes/project-management.json +47 -0
  10. package/agent-recipes/trend-line.json +21 -0
  11. package/docs/agent/README.md +62 -0
  12. package/docs/agent/charting-scenario.md +82 -0
  13. package/docs/agent/coding-agent-integration.md +88 -0
  14. package/docs/agent/development/2.0-release-readiness.md +65 -0
  15. package/docs/agent/development/iteration-2.md +9 -0
  16. package/docs/agent/development/iteration-3.md +142 -0
  17. package/docs/agent/development/iteration-4.md +261 -0
  18. package/docs/agent/development/iteration-5.md +43 -0
  19. package/docs/agent/development/iteration-6.md +130 -0
  20. package/docs/agent/development/iteration-7.md +117 -0
  21. package/docs/agent/development/iteration-8-acceptance.md +68 -0
  22. package/docs/agent/development/iteration-8.md +116 -0
  23. package/docs/agent/development/iteration-9.md +44 -0
  24. package/docs/agent/development/playground-plan.md +143 -0
  25. package/docs/agent/development/prompt-contract.md +22 -0
  26. package/docs/agent/development/rc-1-acceptance.md +43 -0
  27. package/docs/agent/development/roadmap.md +259 -0
  28. package/docs/agent/development-guide.md +60 -0
  29. package/docs/agent/diagram-scenario.md +82 -0
  30. package/docs/agent/editing-contract.md +71 -0
  31. package/docs/agent/frontend-integration.md +66 -0
  32. package/docs/agent/project-scenario.md +89 -0
  33. package/docs/agent/quickstart.md +205 -0
  34. package/docs/agent/runtime-contract.md +132 -0
  35. package/docs/agent/theme-guide.md +59 -0
  36. package/docs/agent/zh-CN/README.md +26 -0
  37. package/docs/agent/zh-CN/charting-scenario.md +62 -0
  38. package/docs/agent/zh-CN/coding-agent-integration.md +36 -0
  39. package/docs/agent/zh-CN/development-guide.md +31 -0
  40. package/docs/agent/zh-CN/diagram-scenario.md +37 -0
  41. package/docs/agent/zh-CN/editing-contract.md +27 -0
  42. package/docs/agent/zh-CN/frontend-integration.md +31 -0
  43. package/docs/agent/zh-CN/project-scenario.md +40 -0
  44. package/docs/agent/zh-CN/quickstart.md +84 -0
  45. package/docs/agent/zh-CN/runtime-contract.md +85 -0
  46. package/docs/agent/zh-CN/theme-guide.md +50 -0
  47. package/docs/manifests/capabilities.json +79 -0
  48. package/docs/manifests/commands.json +30 -0
  49. package/docs/manifests/schemas.json +13 -0
  50. package/examples/agent-workflow.mjs +94 -0
  51. package/package.json +55 -0
  52. package/skills/ichartjs/SKILL.md +72 -0
  53. package/skills/ichartjs/agents/openai.yaml +4 -0
  54. package/skills/ichartjs/references/agent-contract.md +39 -0
  55. package/skills/ichartjs/references/chart-selection.md +22 -0
  56. package/src/capabilities.mjs +190 -0
  57. package/src/charts.mjs +234 -0
  58. package/src/command.mjs +93 -0
  59. package/src/data.mjs +75 -0
  60. package/src/diagram-interaction.mjs +195 -0
  61. package/src/diagram.mjs +135 -0
  62. package/src/edit-controller.mjs +192 -0
  63. package/src/edit.mjs +369 -0
  64. package/src/format.mjs +19 -0
  65. package/src/history.mjs +14 -0
  66. package/src/index.mjs +562 -0
  67. package/src/plugin.mjs +18 -0
  68. package/src/project-analytics.mjs +357 -0
  69. package/src/project-linking.mjs +72 -0
  70. package/src/project.mjs +370 -0
  71. package/src/recipes.mjs +18 -0
  72. package/src/renderer.mjs +77 -0
  73. package/src/scale.mjs +9 -0
  74. package/src/scene.mjs +18 -0
  75. package/src/schema.mjs +142 -0
  76. package/src/spec.mjs +127 -0
  77. package/src/theme.mjs +232 -0
  78. package/src/transforms.mjs +34 -0
  79. package/src/validation.mjs +106 -0
  80. package/types/index.d.ts +134 -0
package/src/edit.mjs ADDED
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Business and diagram edit preview/commit primitives.
3
+ * Validates schemas and commands before producing deterministic patches.
4
+ */
5
+ import { copyJSON, getBusinessSchema, issue, validateDataSchema } from './schema.mjs';
6
+ import { validateData } from './validation.mjs';
7
+ import { commandCapabilities, normalizeCommand, validateCommand } from './command.mjs';
8
+ import { validateDiagram } from './diagram.mjs';
9
+
10
+ const day = 86400000;
11
+ const plusDays = (value, days) => { const date = new Date(`${value}T00:00:00.000Z`); date.setUTCDate(date.getUTCDate() + days); return date.toISOString().slice(0, 10); };
12
+ const equality = (left, right) => JSON.stringify(left) === JSON.stringify(right);
13
+ const recordId = operation => operation.recordId ?? operation.taskId ?? operation.nodeId ?? operation.edgeId;
14
+ const diagramOperationTypes = new Set(['moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'toggleGroupCollapse', 'addEdge', 'duplicateSelection', 'pasteSelection']);
15
+ const structureOperationTypes = new Set(['assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'toggleGroupCollapse', 'addEdge', 'duplicateSelection', 'pasteSelection']);
16
+
17
+ export function businessModelForType(type) {
18
+ return { gantt: 'project-task', timeline: 'timeline-event', milestone: 'milestone', burndown: 'burndown-sample', flow: 'flow-node', swimlane: 'flow-node' }[type] || type;
19
+ }
20
+
21
+ function jsonValue(value) { return value === undefined ? null : copyJSON(value); }
22
+ function change(before, after, path, operation) { return { path, before: jsonValue(before), after: jsonValue(after), operation: operation.op }; }
23
+ function nextId(rows, prefix) { let index = rows.length + 1; while (rows.some(row => row?.id === `${prefix}-${index}`)) index += 1; return `${prefix}-${index}`; }
24
+ function uniqueId(existing, preferred) { let index = 1, value = preferred; while (existing.has(value)) value = `${preferred}-${index++}`; existing.add(value); return value; }
25
+ function duplicateEdge(edges, edge) { return edges.some(item => item.from === edge.from && item.to === edge.to && (item.fromPort || null) === (edge.fromPort || null) && (item.toPort || null) === (edge.toPort || null)); }
26
+
27
+ function previewDiagramEdit(command, options = {}) {
28
+ const nodeSchemaResult = validateDataSchema(options.nodeSchema || options.schema || getBusinessSchema('flow-node'));
29
+ const edgeSchemaResult = validateDataSchema(options.edgeSchema || getBusinessSchema('flow-edge'));
30
+ const errors = [...nodeSchemaResult.errors, ...edgeSchemaResult.errors], warnings = [], changes = [], patches = [], affected = new Set();
31
+ const beforeNodes = copyJSON(options.nodes || options.values || []), beforeEdges = copyJSON(options.edges || []), beforeGroups = copyJSON(options.groups || []), lanes = copyJSON(options.lanes || []);
32
+ if (!nodeSchemaResult.valid || !edgeSchemaResult.valid) return result({ valid: false, errors, command, changes, patches, affectedRecords: [], warnings, requiresConfirmation: false });
33
+ const nodes = copyJSON(beforeNodes), edges = copyJSON(beforeEdges), groups = copyJSON(beforeGroups), nodeIds = new Set(nodes.map(row => row.id)), edgeIds = new Set(edges.map(row => row.id).filter(Boolean));
34
+ const nodeIndex = () => new Map(nodes.map((row, rowIndex) => [row.id, { row, rowIndex }]));
35
+ const edgeIndex = () => new Map(edges.map((row, rowIndex) => [row.id || `edge-${rowIndex}`, { row, rowIndex }]));
36
+ const groupIndex = () => new Map(groups.map((row, rowIndex) => [row.id, { row, rowIndex }]));
37
+ const editable = (schema, field) => schema?.fields?.[field]?.editable !== false && schema?.fields?.[field]?.agentEditable !== false;
38
+ const setField = (collection, hit, field, value, operation, opIndex, schemaName) => {
39
+ const schema = schemaName === 'edges' ? edgeSchemaResult.schema : nodeSchemaResult.schema;
40
+ if (!editable(schema, field)) { errors.push(issue('READ_ONLY_FIELD', `command.operations.${opIndex}.changes.${field}`, `Field ${field} is not editable by an Agent.`)); return; }
41
+ const before = hit.row[field];
42
+ hit.row[field] = copyJSON(value);
43
+ changes.push(change(before, hit.row[field], `${schemaName}.${hit.rowIndex}.${field}`, operation));
44
+ patches.push({ op: 'replace', path: `/${schemaName}/${hit.rowIndex}/${field}`, value: copyJSON(value) });
45
+ if (hit.row.id) affected.add(hit.row.id);
46
+ };
47
+ const ensureStructureAllowed = (operation, opIndex) => {
48
+ if (!structureOperationTypes.has(operation.op)) return true;
49
+ if (options.allowStructuralChanges === true) return true;
50
+ errors.push(issue('STRUCTURAL_EDIT_DISABLED', `command.operations.${opIndex}`, 'Structural diagram edits require editing.allowStructuralChanges.'));
51
+ return false;
52
+ };
53
+
54
+ command.operations.forEach((operation, opIndex) => {
55
+ if (!diagramOperationTypes.has(operation.op)) return;
56
+ if (!ensureStructureAllowed(operation, opIndex)) return;
57
+ if (['moveNodes', 'alignNodes', 'snapNodes'].includes(operation.op)) {
58
+ const index = nodeIndex(), hits = operation.nodeIds.map(id => index.get(id));
59
+ if (hits.some(hit => !hit)) { errors.push(issue('RECORD_NOT_FOUND', `command.operations.${opIndex}.nodeIds`, 'One or more diagram nodes were not found.')); return; }
60
+ const positions = hits.map(hit => ({ hit, position: { ...(hit.row.position || { x: 0, y: 0 }) } }));
61
+ if (operation.op === 'moveNodes') positions.forEach(item => { item.position.x += operation.delta.x; item.position.y += operation.delta.y; });
62
+ if (operation.op === 'alignNodes') {
63
+ const sizes = positions.map(item => ({ width: Number(item.hit.row.size?.width) || 112, height: Number(item.hit.row.size?.height) || 36 }));
64
+ const bounds = positions.map((item, index) => ({ left: item.position.x, top: item.position.y, right: item.position.x + sizes[index].width, bottom: item.position.y + sizes[index].height, center: item.position.x + sizes[index].width / 2, middle: item.position.y + sizes[index].height / 2 }));
65
+ const target = operation.alignment === 'left' ? Math.min(...bounds.map(item => item.left))
66
+ : operation.alignment === 'right' ? Math.max(...bounds.map(item => item.right))
67
+ : operation.alignment === 'top' ? Math.min(...bounds.map(item => item.top))
68
+ : operation.alignment === 'bottom' ? Math.max(...bounds.map(item => item.bottom))
69
+ : operation.alignment === 'center' ? bounds.reduce((sum, item) => sum + item.center, 0) / bounds.length
70
+ : bounds.reduce((sum, item) => sum + item.middle, 0) / bounds.length;
71
+ positions.forEach((item, index) => {
72
+ if (operation.alignment === 'left') item.position.x = target;
73
+ else if (operation.alignment === 'right') item.position.x = target - sizes[index].width;
74
+ else if (operation.alignment === 'top') item.position.y = target;
75
+ else if (operation.alignment === 'bottom') item.position.y = target - sizes[index].height;
76
+ else if (operation.alignment === 'center') item.position.x = target - sizes[index].width / 2;
77
+ else item.position.y = target - sizes[index].height / 2;
78
+ });
79
+ }
80
+ if (operation.op === 'snapNodes') positions.forEach(item => { const grid = Number(options.grid) || 8; item.position.x = Math.round(item.position.x / grid) * grid; item.position.y = Math.round(item.position.y / grid) * grid; });
81
+ positions.forEach(item => setField(nodes, item.hit, 'position', item.position, operation, opIndex, 'nodes'));
82
+ return;
83
+ }
84
+
85
+ if (['moveNode', 'resizeNode', 'moveNodeToLane'].includes(operation.op)) {
86
+ const hit = nodeIndex().get(recordId(operation));
87
+ if (!hit) { errors.push(issue('RECORD_NOT_FOUND', `command.operations.${opIndex}`, `Record ${recordId(operation)} was not found.`)); return; }
88
+ if (operation.op === 'moveNode') setField(nodes, hit, 'position', operation.position, operation, opIndex, 'nodes');
89
+ if (operation.op === 'resizeNode') setField(nodes, hit, 'size', operation.size, operation, opIndex, 'nodes');
90
+ if (operation.op === 'moveNodeToLane') setField(nodes, hit, 'laneId', operation.laneId, operation, opIndex, 'nodes');
91
+ return;
92
+ }
93
+
94
+ if (operation.op === 'moveGroup') {
95
+ if (!groupIndex().has(operation.groupId)) { errors.push(issue('GROUP_NOT_FOUND', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} was not found.`)); return; }
96
+ const hits = nodes.filter(node => node.groupId === operation.groupId).map(node => nodeIndex().get(node.id));
97
+ if (!hits.length) { errors.push(issue('EMPTY_GROUP', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} has no member nodes.`)); return; }
98
+ hits.forEach(hit => setField(nodes, hit, 'position', { x: Number(hit.row.position?.x || 0) + operation.delta.x, y: Number(hit.row.position?.y || 0) + operation.delta.y }, operation, opIndex, 'nodes'));
99
+ return;
100
+ }
101
+
102
+ if (operation.op === 'resizeGroup') {
103
+ if (!groupIndex().has(operation.groupId)) { errors.push(issue('GROUP_NOT_FOUND', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} was not found.`)); return; }
104
+ const hits = nodes.filter(node => node.groupId === operation.groupId).map(node => nodeIndex().get(node.id));
105
+ if (!hits.length) { errors.push(issue('EMPTY_GROUP', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} has no member nodes.`)); return; }
106
+ const boxes = hits.map(hit => ({ x: Number(hit.row.position?.x || 0), y: Number(hit.row.position?.y || 0), width: Number(hit.row.size?.width || 112), height: Number(hit.row.size?.height || 36) }));
107
+ const left = Math.min(...boxes.map(box => box.x)), top = Math.min(...boxes.map(box => box.y)), right = Math.max(...boxes.map(box => box.x + box.width)), bottom = Math.max(...boxes.map(box => box.y + box.height));
108
+ const scaleX = operation.size.width / Math.max(1, right - left), scaleY = operation.size.height / Math.max(1, bottom - top);
109
+ hits.forEach((hit, index) => {
110
+ setField(nodes, hit, 'position', { x: left + (boxes[index].x - left) * scaleX, y: top + (boxes[index].y - top) * scaleY }, operation, opIndex, 'nodes');
111
+ setField(nodes, hit, 'size', { width: boxes[index].width * scaleX, height: boxes[index].height * scaleY }, operation, opIndex, 'nodes');
112
+ });
113
+ return;
114
+ }
115
+
116
+ if (operation.op === 'assignNodesToGroup') {
117
+ if (operation.groupId !== null && !groupIndex().has(operation.groupId)) { errors.push(issue('GROUP_NOT_FOUND', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} was not found.`)); return; }
118
+ const index = nodeIndex(), hits = [...new Set(operation.nodeIds)].map(id => index.get(id));
119
+ if (hits.some(hit => !hit)) { errors.push(issue('RECORD_NOT_FOUND', `command.operations.${opIndex}.nodeIds`, 'One or more diagram nodes were not found.')); return; }
120
+ hits.forEach(hit => {
121
+ const before = hit.row.groupId;
122
+ if (operation.groupId === null) delete hit.row.groupId;
123
+ else hit.row.groupId = operation.groupId;
124
+ affected.add(hit.row.id);
125
+ changes.push(change(before, hit.row.groupId, `nodes.${hit.rowIndex}.groupId`, operation));
126
+ patches.push(operation.groupId === null ? { op: 'remove', path: `/nodes/${hit.rowIndex}/groupId` } : { op: before === undefined ? 'add' : 'replace', path: `/nodes/${hit.rowIndex}/groupId`, value: operation.groupId });
127
+ });
128
+ return;
129
+ }
130
+
131
+ if (operation.op === 'duplicateGroup') {
132
+ const hit = groupIndex().get(operation.groupId);
133
+ if (!hit) { errors.push(issue('GROUP_NOT_FOUND', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} was not found.`)); return; }
134
+ const members = nodes.filter(node => node.groupId === operation.groupId);
135
+ if (!members.length) { errors.push(issue('EMPTY_GROUP', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} has no member nodes.`)); return; }
136
+ const groupId = uniqueId(new Set(groups.map(group => group.id)), `${operation.groupId}-copy`);
137
+ const group = { ...copyJSON(hit.row), id: groupId, label: `${hit.row.label || hit.row.id} copy`, collapsed: false };
138
+ groups.push(group);
139
+ affected.add(group.id);
140
+ changes.push(change(undefined, group, `groups.${groups.length - 1}`, operation));
141
+ patches.push({ op: 'add', path: `/groups/${groups.length - 1}`, value: copyJSON(group) });
142
+ const mapping = new Map(), existingNodeIds = new Set(nodes.map(node => node.id)), offset = operation.offset || { x: (Number(options.grid) || 8) * 2, y: (Number(options.grid) || 8) * 2 };
143
+ members.forEach(node => {
144
+ const id = uniqueId(existingNodeIds, `${node.id}-copy`);
145
+ mapping.set(node.id, id);
146
+ const copy = { ...copyJSON(node), id, groupId, position: { x: Number(node.position?.x || 0) + Number(offset.x || 0), y: Number(node.position?.y || 0) + Number(offset.y || 0) } };
147
+ nodes.push(copy);
148
+ affected.add(copy.id);
149
+ changes.push(change(undefined, copy, `nodes.${nodes.length - 1}`, operation));
150
+ patches.push({ op: 'add', path: `/nodes/${nodes.length - 1}`, value: copyJSON(copy) });
151
+ });
152
+ edges.filter(edge => mapping.has(edge.from) && mapping.has(edge.to)).forEach(edge => {
153
+ const copy = { ...copyJSON(edge), id: uniqueId(edgeIds, `${edge.id || 'edge'}-copy`), from: mapping.get(edge.from), to: mapping.get(edge.to) };
154
+ edges.push(copy);
155
+ affected.add(copy.id);
156
+ changes.push(change(undefined, copy, `edges.${edges.length - 1}`, operation));
157
+ patches.push({ op: 'add', path: `/edges/${edges.length - 1}`, value: copyJSON(copy) });
158
+ });
159
+ return;
160
+ }
161
+
162
+ if (operation.op === 'deleteGroup') {
163
+ const hit = groupIndex().get(operation.groupId);
164
+ if (!hit) { errors.push(issue('GROUP_NOT_FOUND', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} was not found.`)); return; }
165
+ const memberIds = new Set(nodes.filter(node => node.groupId === operation.groupId).map(node => node.id));
166
+ if ((operation.policy || 'ungroup') === 'delete-members') {
167
+ for (let index = edges.length - 1; index >= 0; index -= 1) if (memberIds.has(edges[index].from) || memberIds.has(edges[index].to)) { const removed = edges.splice(index, 1)[0]; affected.add(removed.id || `edge-${index}`); changes.push(change(removed, undefined, `edges.${index}`, operation)); patches.push({ op: 'remove', path: `/edges/${index}` }); }
168
+ for (let index = nodes.length - 1; index >= 0; index -= 1) if (memberIds.has(nodes[index].id)) { const removed = nodes.splice(index, 1)[0]; affected.add(removed.id); changes.push(change(removed, undefined, `nodes.${index}`, operation)); patches.push({ op: 'remove', path: `/nodes/${index}` }); }
169
+ } else nodes.forEach((node, index) => { if (node.groupId === operation.groupId) { delete node.groupId; affected.add(node.id); changes.push(change(operation.groupId, undefined, `nodes.${index}.groupId`, operation)); patches.push({ op: 'remove', path: `/nodes/${index}/groupId` }); } });
170
+ const removed = groups.splice(hit.rowIndex, 1)[0];
171
+ affected.add(removed.id);
172
+ changes.push(change(removed, undefined, `groups.${hit.rowIndex}`, operation));
173
+ patches.push({ op: 'remove', path: `/groups/${hit.rowIndex}` });
174
+ return;
175
+ }
176
+
177
+ if (operation.op === 'updateEdge') {
178
+ const hit = edgeIndex().get(recordId(operation));
179
+ if (!hit) { errors.push(issue('RECORD_NOT_FOUND', `command.operations.${opIndex}`, `Record ${recordId(operation)} was not found.`)); return; }
180
+ Object.entries(operation.changes || {}).forEach(([field, value]) => setField(edges, hit, field, value, operation, opIndex, 'edges'));
181
+ return;
182
+ }
183
+
184
+ if (operation.op === 'addEdge') {
185
+ const edge = {
186
+ id: operation.id || nextId(edges, 'edge'),
187
+ from: operation.from,
188
+ to: operation.to,
189
+ ...(operation.fromPort ? { fromPort: operation.fromPort } : {}),
190
+ ...(operation.toPort ? { toPort: operation.toPort } : {}),
191
+ ...(operation.label ? { label: operation.label } : {}),
192
+ ...(operation.status ? { status: operation.status } : {}),
193
+ ...(operation.routing ? { routing: operation.routing } : {})
194
+ };
195
+ if (duplicateEdge(edges, edge)) { errors.push(issue('DUPLICATE_EDGE', `command.operations.${opIndex}`, 'An equivalent edge already exists.')); return; }
196
+ edges.push(edge);
197
+ edgeIds.add(edge.id);
198
+ affected.add(edge.id);
199
+ changes.push(change(undefined, edge, `edges.${edges.length - 1}`, operation));
200
+ patches.push({ op: 'add', path: `/edges/${edges.length - 1}`, value: copyJSON(edge) });
201
+ return;
202
+ }
203
+
204
+ if (operation.op === 'toggleGroupCollapse') {
205
+ const hit = groupIndex().get(operation.groupId);
206
+ if (!hit) { errors.push(issue('GROUP_NOT_FOUND', `command.operations.${opIndex}.groupId`, `Group ${operation.groupId} was not found.`)); return; }
207
+ const next = operation.collapsed ?? !hit.row.collapsed;
208
+ const before = Boolean(hit.row.collapsed);
209
+ hit.row.collapsed = next;
210
+ affected.add(hit.row.id);
211
+ changes.push(change(before, next, `groups.${hit.rowIndex}.collapsed`, operation));
212
+ patches.push({ op: before === undefined ? 'add' : 'replace', path: `/groups/${hit.rowIndex}/collapsed`, value: next });
213
+ return;
214
+ }
215
+
216
+ const paste = (payloadNodes = [], payloadEdges = [], offset = { x: (Number(options.grid) || 8) * 2, y: (Number(options.grid) || 8) * 2 }, operationRef = operation) => {
217
+ const mapping = new Map(), existing = new Set(nodes.map(node => node.id));
218
+ payloadNodes.forEach(node => {
219
+ const baseId = node.id || nextId(nodes, 'node');
220
+ const id = uniqueId(existing, `${baseId}-copy`);
221
+ mapping.set(node.id, id);
222
+ const copy = {
223
+ ...copyJSON(node),
224
+ id,
225
+ position: {
226
+ x: Number(node.position?.x || 0) + Number(offset.x || 0),
227
+ y: Number(node.position?.y || 0) + Number(offset.y || 0)
228
+ }
229
+ };
230
+ nodes.push(copy);
231
+ affected.add(copy.id);
232
+ changes.push(change(undefined, copy, `nodes.${nodes.length - 1}`, operationRef));
233
+ patches.push({ op: 'add', path: `/nodes/${nodes.length - 1}`, value: copyJSON(copy) });
234
+ });
235
+ payloadEdges.filter(edge => mapping.has(edge.from) && mapping.has(edge.to)).forEach(edge => {
236
+ const id = uniqueId(edgeIds, `${edge.id || 'edge'}-copy`);
237
+ const copy = {
238
+ ...copyJSON(edge),
239
+ id,
240
+ from: mapping.get(edge.from),
241
+ to: mapping.get(edge.to)
242
+ };
243
+ if (duplicateEdge(edges, copy)) return;
244
+ edges.push(copy);
245
+ affected.add(copy.id);
246
+ changes.push(change(undefined, copy, `edges.${edges.length - 1}`, operationRef));
247
+ patches.push({ op: 'add', path: `/edges/${edges.length - 1}`, value: copyJSON(copy) });
248
+ });
249
+ };
250
+
251
+ if (operation.op === 'duplicateSelection') {
252
+ const index = nodeIndex(), selected = [...new Set(operation.nodeIds)].map(id => index.get(id)?.row).filter(Boolean);
253
+ if (!selected.length) { errors.push(issue('RECORD_NOT_FOUND', `command.operations.${opIndex}.nodeIds`, 'One or more diagram nodes were not found.')); return; }
254
+ const selectedIds = new Set(selected.map(node => node.id));
255
+ paste(selected, edges.filter(edge => selectedIds.has(edge.from) && selectedIds.has(edge.to)), operation.offset || { x: (Number(options.grid) || 8) * 2, y: (Number(options.grid) || 8) * 2 });
256
+ return;
257
+ }
258
+
259
+ if (operation.op === 'pasteSelection') {
260
+ paste(operation.nodes || [], operation.edges || [], operation.offset || { x: (Number(options.grid) || 8) * 2, y: (Number(options.grid) || 8) * 2 });
261
+ }
262
+ });
263
+
264
+ const nodeValidation = validateData(nodes, nodeSchemaResult.schema, { ...options.validationOptions, references: { ...options.validationOptions?.references, swimlane: lanes.map(lane => lane.id) } });
265
+ const edgeValidation = validateData(edges, edgeSchemaResult.schema, { ...options.validationOptions, references: { ...options.validationOptions?.references, 'flow-node': nodeValidation.rows.map(node => node.id) } });
266
+ errors.push(...nodeValidation.errors, ...edgeValidation.errors);
267
+ warnings.push(...nodeValidation.warnings, ...edgeValidation.warnings);
268
+ const diagramSpec = { type: 'flow', nodes: nodeValidation.rows, edges: edgeValidation.rows, groups, lanes, ...(options.diagram ? { diagram: options.diagram } : {}) };
269
+ const diagramValidation = validateDiagram(diagramSpec);
270
+ errors.push(...diagramValidation.errors.map(error => issue(error.code, error.path, error.message, error.suggestion)));
271
+
272
+ const targets = {};
273
+ if (!equality(beforeNodes, nodeValidation.rows)) targets.nodes = nodeValidation.rows;
274
+ if (!equality(beforeEdges, edgeValidation.rows)) targets.edges = edgeValidation.rows;
275
+ if (!equality(beforeGroups, groups)) targets.groups = groups;
276
+ if (errors.length) return result({ valid: false, errors, command, changes: [], patches: [], affectedRecords: [...affected], warnings, requiresConfirmation: false, before: { nodes: beforeNodes, edges: beforeEdges, groups: beforeGroups }, after: { nodes: beforeNodes, edges: beforeEdges, groups: beforeGroups }, targets: {} });
277
+ const requiresConfirmation = options.requireConfirmation !== false || command.operations.some(operation => structureOperationTypes.has(operation.op) || operation.op === 'updateEdge');
278
+ return result({
279
+ valid: true,
280
+ errors: [],
281
+ command,
282
+ changes,
283
+ patches,
284
+ affectedRecords: [...affected],
285
+ warnings,
286
+ requiresConfirmation,
287
+ before: { nodes: beforeNodes, edges: beforeEdges, groups: beforeGroups },
288
+ after: { nodes: nodeValidation.rows, edges: edgeValidation.rows, groups },
289
+ targets,
290
+ rows: targets.nodes || nodeValidation.rows,
291
+ normalized: targets.nodes || nodeValidation.rows,
292
+ capabilities: { ...commandCapabilities(nodeSchemaResult.schema), ...commandCapabilities(edgeSchemaResult.schema) }
293
+ });
294
+ }
295
+
296
+ function previewBusinessEdit(command, options = {}) {
297
+ const commandResult = validateCommand(command), schemaResult = validateDataSchema(options.schema), errors = [...commandResult.errors, ...schemaResult.errors], warnings = [], changes = [], patches = [], affected = new Set();
298
+ if (!commandResult.valid || !schemaResult.valid) return result({ valid: false, errors, command, changes, patches, affectedRecords: [], warnings, requiresConfirmation: false });
299
+ if (command.type === 'layout-edit' && !['flow-node', 'flow-edge'].includes(schemaResult.schema.name)) errors.push(issue('COMMAND_MODEL', 'command.type', 'layout-edit only supports diagram models.'));
300
+ const inputRows = copyJSON(options.values || []), validation = validateData(inputRows, schemaResult.schema, options.validationOptions), rows = validation.rows;
301
+ errors.push(...validation.errors); warnings.push(...validation.warnings);
302
+ const key = schemaResult.schema.key, index = new Map(rows.map((row, rowIndex) => [row[key], { row, rowIndex }])), editable = name => schemaResult.schema.fields[name]?.editable !== false && schemaResult.schema.fields[name]?.agentEditable !== false;
303
+ const target = operation => index.get(recordId(operation));
304
+ const requireTarget = (operation, opIndex) => { const hit = target(operation); if (!hit) errors.push(issue('RECORD_NOT_FOUND', `command.operations.${opIndex}`, `Record ${recordId(operation)} was not found.`)); return hit; };
305
+ const setField = (hit, field, value, operation, opIndex) => {
306
+ if (!Object.hasOwn(schemaResult.schema.fields, field)) { errors.push(issue('UNKNOWN_FIELD', `command.operations.${opIndex}.changes.${field}`, 'Field is not declared in the schema.')); return; }
307
+ if (!editable(field)) { errors.push(issue('READ_ONLY_FIELD', `command.operations.${opIndex}.changes.${field}`, `Field ${field} is not editable by an Agent.`)); return; }
308
+ const before = hit.row[field]; hit.row[field] = copyJSON(value); changes.push(change(before, hit.row[field], `values.${hit.rowIndex}.${field}`, operation)); patches.push({ op: 'replace', path: `/values/${hit.rowIndex}/${field}`, value: copyJSON(value) }); affected.add(hit.row[key]);
309
+ };
310
+ command.operations.forEach((operation, opIndex) => {
311
+ if (['moveNodes', 'alignNodes', 'snapNodes'].includes(operation.op)) {
312
+ const hits = operation.nodeIds.map(id => index.get(id));
313
+ if (hits.some(hit => !hit)) { errors.push(issue('RECORD_NOT_FOUND', `command.operations.${opIndex}.nodeIds`, 'One or more diagram nodes were not found.')); return; }
314
+ const positions = hits.map(hit => ({ hit, position: { ...(hit.row.position || { x: 0, y: 0 }) } }));
315
+ if (operation.op === 'moveNodes') positions.forEach(item => { item.position.x += operation.delta.x; item.position.y += operation.delta.y; });
316
+ if (operation.op === 'alignNodes') {
317
+ const sizes = positions.map(item => ({ width: Number(item.hit.row.size?.width) || 112, height: Number(item.hit.row.size?.height) || 36 })), bounds = positions.map((item, index) => ({ left: item.position.x, top: item.position.y, right: item.position.x + sizes[index].width, bottom: item.position.y + sizes[index].height, center: item.position.x + sizes[index].width / 2, middle: item.position.y + sizes[index].height / 2 }));
318
+ const target = operation.alignment === 'left' ? Math.min(...bounds.map(item => item.left)) : operation.alignment === 'right' ? Math.max(...bounds.map(item => item.right)) : operation.alignment === 'top' ? Math.min(...bounds.map(item => item.top)) : operation.alignment === 'bottom' ? Math.max(...bounds.map(item => item.bottom)) : operation.alignment === 'center' ? bounds.reduce((sum, item) => sum + item.center, 0) / bounds.length : bounds.reduce((sum, item) => sum + item.middle, 0) / bounds.length;
319
+ positions.forEach((item, index) => { if (operation.alignment === 'left') item.position.x = target; else if (operation.alignment === 'right') item.position.x = target - sizes[index].width; else if (operation.alignment === 'top') item.position.y = target; else if (operation.alignment === 'bottom') item.position.y = target - sizes[index].height; else if (operation.alignment === 'center') item.position.x = target - sizes[index].width / 2; else item.position.y = target - sizes[index].height / 2; });
320
+ }
321
+ if (operation.op === 'snapNodes') positions.forEach(item => { const grid = Number(options.grid) || 8; item.position.x = Math.round(item.position.x / grid) * grid; item.position.y = Math.round(item.position.y / grid) * grid; });
322
+ positions.forEach(item => setField(item.hit, 'position', item.position, operation, opIndex));
323
+ return;
324
+ }
325
+ const hit = requireTarget(operation, opIndex); if (!hit) return;
326
+ if (operation.op === 'updateField') setField(hit, operation.field, operation.value, operation, opIndex);
327
+ if (['updateRecord', 'updateTask', 'updateMilestone'].includes(operation.op)) Object.entries(operation.changes || {}).forEach(([field, value]) => setField(hit, field, value, operation, opIndex));
328
+ if (operation.op === 'updateProgress') { setField(hit, 'progress', operation.progress, operation, opIndex); if (operation.status !== undefined) setField(hit, 'status', operation.status, operation, opIndex); }
329
+ if (operation.op === 'shiftTask') {
330
+ if (!equality(hit.row.status, 'done') || operation.allowCompleted) { setField(hit, 'start', plusDays(hit.row.start, operation.days), operation, opIndex); setField(hit, 'end', plusDays(hit.row.end, operation.days), operation, opIndex); }
331
+ else warnings.push(issue('COMPLETED_TASK', `command.operations.${opIndex}`, 'Completed task schedule change requires explicit allowCompleted.'));
332
+ if (operation.shiftDependents) rows.filter(row => (row.dependencies || []).includes(hit.row[key])).forEach(row => { const dependent = index.get(row[key]); if (dependent) { const days = operation.days; setField(dependent, 'start', plusDays(dependent.row.start, days), operation, opIndex); setField(dependent, 'end', plusDays(dependent.row.end, days), operation, opIndex); } });
333
+ }
334
+ if (['addDependency', 'removeDependency'].includes(operation.op)) {
335
+ const current = Array.isArray(hit.row.dependencies) ? [...hit.row.dependencies] : [], next = operation.op === 'addDependency' ? [...new Set([...current, operation.dependencyId])] : current.filter(id => id !== operation.dependencyId); setField(hit, 'dependencies', next, operation, opIndex);
336
+ }
337
+ if (operation.op === 'moveNode') setField(hit, 'position', operation.position, operation, opIndex);
338
+ if (operation.op === 'resizeNode') setField(hit, 'size', operation.size, operation, opIndex);
339
+ if (operation.op === 'moveNodeToLane') setField(hit, 'laneId', operation.laneId, operation, opIndex);
340
+ if (operation.op === 'updateEdge') Object.entries(operation.changes || {}).forEach(([field, value]) => setField(hit, field, value, operation, opIndex));
341
+ });
342
+ const finalValidation = validateData(rows, schemaResult.schema, options.validationOptions); errors.push(...finalValidation.errors); warnings.push(...finalValidation.warnings);
343
+ if (errors.length) return result({ valid: false, errors, command, changes: [], patches: [], affectedRecords: [...affected], warnings, requiresConfirmation: false, before: inputRows, after: inputRows });
344
+ const requiresConfirmation = options.requireConfirmation !== false || command.operations.some(operation => ['addRecord', 'removeRecord', 'addDependency', 'removeDependency', 'updateEdge'].includes(operation.op));
345
+ return result({ valid: true, errors: [], command, changes, patches, affectedRecords: [...affected], warnings, requiresConfirmation, before: inputRows, after: rows, normalized: finalValidation.rows, capabilities: commandCapabilities(schemaResult.schema), rows: finalValidation.rows, targets: { values: finalValidation.rows } });
346
+ }
347
+
348
+ export function previewEdit(input, options = {}) {
349
+ let command;
350
+ try { command = normalizeCommand(input); } catch (error) { return { valid: false, errors: [issue('INVALID_COMMAND', 'command', error.message)], changes: [], patches: [], affectedRecords: [], warnings: [], requiresConfirmation: false }; }
351
+ const commandResult = validateCommand(command);
352
+ if (!commandResult.valid) return result({ valid: false, errors: commandResult.errors, command, changes: [], patches: [], affectedRecords: [], warnings: [], requiresConfirmation: false });
353
+ if (command.operations.some(operation => diagramOperationTypes.has(operation.op))) return previewDiagramEdit(command, options);
354
+ return previewBusinessEdit(command, options);
355
+ }
356
+
357
+ function result(value) { return { ...value, id: value.id || `preview-${JSON.stringify(value.command || {}).length}-${(value.changes || []).length}`, revision: value.revision ?? null, assumptions: ['Only declared editable fields may change.', 'Preview operates on a copied local dataset.', 'External persistence and authorization remain the host application responsibility.'] }; }
358
+
359
+ export function validateEdit(input, options = {}) { const preview = previewEdit(input, options); return { valid: preview.valid, errors: preview.errors, warnings: preview.warnings, affectedRecords: preview.affectedRecords, requiresConfirmation: preview.requiresConfirmation }; }
360
+
361
+ export function getEditCapabilities(schema) { return commandCapabilities(validateDataSchema(schema).schema || {}); }
362
+
363
+ export function commitPreview(preview, options = {}) {
364
+ if (!preview?.valid) return { valid: false, errors: [issue('INVALID_PREVIEW', 'preview', 'Only a valid preview can be committed.')], warnings: [] };
365
+ if (preview.requiresConfirmation && options.confirmed !== true && !options.approval) return { valid: false, errors: [issue('CONFIRMATION_REQUIRED', 'approval', 'This edit requires explicit host confirmation.')], warnings: preview.warnings || [] };
366
+ if (options.previewId !== undefined && options.previewId !== preview.id) return { valid: false, errors: [issue('PREVIEW_MISMATCH', 'previewId', 'Approval does not match this preview.')], warnings: [] };
367
+ if (options.expectedRevision !== undefined && options.expectedRevision !== preview.revision) return { valid: false, errors: [issue('STALE_PREVIEW', 'expectedRevision', 'The preview was created from a stale data revision.')], warnings: [] };
368
+ return { valid: true, rows: copyJSON(preview.normalized || preview.after), targets: copyJSON(preview.targets || { values: preview.normalized || preview.after }), changes: copyJSON(preview.changes), patches: copyJSON(preview.patches), affectedRecords: copyJSON(preview.affectedRecords), warnings: copyJSON(preview.warnings || []), command: copyJSON(preview.command), audit: { actor: options.actor || 'host', timestamp: options.timestamp || new Date().toISOString(), reason: preview.command?.reason || options.reason || 'business edit', source: options.source || 'agent', previewId: preview.id } };
369
+ }
package/src/format.mjs ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * JSON-safe value formatting shared by renderers and Agent descriptions.
3
+ */
4
+ export function formatValue(value, format, locale = 'en-US') {
5
+ if (value == null || value === '') return '';
6
+ if (!format) return typeof value === 'number' ? String(Number(value.toFixed(6))) : String(value);
7
+ const options = typeof format === 'string' ? { style: format } : format;
8
+ const prefix = options.prefix || '', suffix = options.suffix || '';
9
+ if (options.style === 'date') {
10
+ const date = new Date(value);
11
+ if (Number.isNaN(date.getTime())) return String(value);
12
+ return `${prefix}${new Intl.DateTimeFormat(options.locale || locale, options.options || { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(date)}${suffix}`;
13
+ }
14
+ const numeric = Number(value);
15
+ if (!Number.isFinite(numeric)) return String(value);
16
+ if (options.style === 'percent') return `${prefix}${new Intl.NumberFormat(options.locale || locale, { style: 'percent', maximumFractionDigits: options.maximumFractionDigits ?? 1 }).format(options.ratio === false ? numeric / 100 : numeric)}${suffix}`;
17
+ if (options.style === 'currency') return `${prefix}${new Intl.NumberFormat(options.locale || locale, { style: 'currency', currency: options.currency || 'USD', maximumFractionDigits: options.maximumFractionDigits ?? 2 }).format(numeric)}${suffix}`;
18
+ return `${prefix}${new Intl.NumberFormat(options.locale || locale, { maximumFractionDigits: options.maximumFractionDigits ?? 2, minimumFractionDigits: options.minimumFractionDigits }).format(numeric)}${suffix}`;
19
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Bounded undo/redo history for committed local chart edits.
3
+ * Stores immutable before/after snapshots supplied by the edit controller.
4
+ */
5
+ import { copyJSON } from './schema.mjs';
6
+
7
+ export class EditHistory {
8
+ constructor(limit = 100) { this.limit = limit; this.undoStack = []; this.redoStack = []; }
9
+ push(entry) { this.undoStack.push(copyJSON(entry)); if (this.undoStack.length > this.limit) this.undoStack.shift(); this.redoStack = []; }
10
+ undo() { const entry = this.undoStack.pop(); if (!entry) return null; this.redoStack.push(copyJSON(entry)); return copyJSON(entry); }
11
+ redo() { const entry = this.redoStack.pop(); if (!entry) return null; this.undoStack.push(copyJSON(entry)); return copyJSON(entry); }
12
+ clear() { this.undoStack = []; this.redoStack = []; }
13
+ state() { return { undo: this.undoStack.length, redo: this.redoStack.length }; }
14
+ }