@taylorwong/ichartjs 2.0.13 → 2.0.15

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 (35) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -2
  3. package/docs/agent/charting-scenario.md +1 -1
  4. package/docs/agent/coding-agent-integration.md +1 -1
  5. package/docs/agent/development/2.0-release-readiness.md +4 -0
  6. package/docs/agent/development/iteration-13.md +318 -0
  7. package/docs/agent/development/roadmap.md +2 -1
  8. package/docs/agent/editing-contract.md +3 -0
  9. package/docs/agent/frontend-integration.md +1 -1
  10. package/docs/agent/runtime-contract.md +4 -0
  11. package/docs/agent/usage-scenarios.md +1 -1
  12. package/docs/agent/zh-CN/charting-scenario.md +1 -1
  13. package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
  14. package/docs/agent/zh-CN/editing-contract.md +2 -0
  15. package/docs/agent/zh-CN/frontend-integration.md +1 -1
  16. package/docs/agent/zh-CN/runtime-contract.md +3 -1
  17. package/docs/agent/zh-CN/usage-scenarios.md +1 -1
  18. package/docs/manifests/capabilities.json +1310 -32
  19. package/docs/manifests/commands.json +7 -7
  20. package/docs/manifests/schemas.json +182 -10
  21. package/examples/agent-workflow.mjs +10 -0
  22. package/package.json +21 -5
  23. package/skills/ichartjs/SKILL.md +1 -1
  24. package/src/capabilities.mjs +10 -36
  25. package/src/chart-lifecycle.mjs +31 -0
  26. package/src/charts.mjs +56 -25
  27. package/src/command.mjs +3 -2
  28. package/src/contract-registry.mjs +59 -0
  29. package/src/edit-controller.mjs +1 -1
  30. package/src/errors.mjs +31 -0
  31. package/src/index.mjs +144 -12
  32. package/src/schema.mjs +1 -0
  33. package/src/spec.mjs +45 -3
  34. package/types/consumer-fixture.ts +35 -0
  35. package/types/index.d.ts +33 -3
package/src/charts.mjs CHANGED
@@ -65,18 +65,30 @@ function axisTicks(min, max, axis = {}, type = 'linear') {
65
65
  return Array.from({ length: intervals + 1 }, (_, index) => min + (max - min) * index / intervals);
66
66
  }
67
67
 
68
+ const axisFreeTypes = new Set(['pie', 'funnel', 'gauge', 'radar']);
69
+
68
70
  function chromeLayout(spec, entries = []) {
69
71
  const title = titleLayout(spec);
70
72
  const visible = spec.legend?.visible !== false && entries.length >= 1;
71
- if (!visible) return { title, legend: { visible: false, items: [], bottom: title.bottom }, bottom: title.bottom };
73
+ if (!visible) return { title, legend: { visible: false, items: [], position: 'top', top: title.bottom, bottom: title.bottom, left: spec.padding.left, right: spec.width - spec.padding.right, width: 0, height: 0 }, bottom: title.bottom };
72
74
  const size = fontSize(spec, 'legend', 12), lineHeight = Math.max(18, size * Number(spec.theme?.typography?.legend?.lineHeight || 1.4));
73
75
  const swatchSize = Math.max(8, Math.round(size * 0.75)), gap = Math.max(12, Math.round(size));
74
76
  const availableWidth = Math.max(1, spec.width - spec.padding.left - spec.padding.right);
75
- const maxLabelWidth = Math.max(size, availableWidth - swatchSize - 6 - gap);
77
+ const requestedPosition = axisFreeTypes.has(spec.type) && ['top', 'right', 'bottom', 'left'].includes(spec.legend?.position) ? spec.legend.position : 'top';
78
+ const sideLegend = requestedPosition === 'left' || requestedPosition === 'right';
79
+ const labelBudget = sideLegend ? Math.max(size * 4, availableWidth * 0.34 - swatchSize - 6 - gap) : availableWidth - swatchSize - 6 - gap;
80
+ const maxLabelWidth = Math.max(size, labelBudget);
76
81
  const measured = entries.map((entry, index) => {
77
82
  const rawName = String(entry.name), name = truncateText(rawName, maxLabelWidth, size);
78
83
  return { ...entry, name, rawName, truncated: name !== rawName, index, width: swatchSize + 6 + estimateTextWidth(name, size) + gap };
79
84
  });
85
+ if (sideLegend) {
86
+ const width = Math.max(size * 4, ...measured.map(item => item.width));
87
+ const top = Math.max(spec.padding.top, title.bottom ? title.bottom + 8 : spec.padding.top);
88
+ const items = measured.map((item, index) => ({ ...item, x: requestedPosition === 'left' ? spec.padding.left : spec.width - spec.padding.right - width, y: top + index * lineHeight + lineHeight / 2, swatchSize }));
89
+ const bottom = top + measured.length * lineHeight;
90
+ return { title, legend: { visible: true, items, position: requestedPosition, top, bottom, left: requestedPosition === 'left' ? spec.padding.left : spec.width - spec.padding.right - width, right: requestedPosition === 'left' ? spec.padding.left + width : spec.width - spec.padding.right, width, height: measured.length * lineHeight, truncatedCount: measured.filter(item => item.truncated).length }, bottom: title.bottom };
91
+ }
80
92
  const rows = [];
81
93
  measured.forEach(item => {
82
94
  let row = rows.at(-1);
@@ -84,7 +96,8 @@ function chromeLayout(spec, entries = []) {
84
96
  row.items.push(item);
85
97
  row.width += item.width;
86
98
  });
87
- const top = title.bottom ? title.bottom + 8 : Math.max(8, (spec.padding.top - rows.length * lineHeight) / 2);
99
+ const legendHeight = rows.length * lineHeight;
100
+ const top = requestedPosition === 'bottom' ? spec.height - spec.padding.bottom - legendHeight : title.bottom ? title.bottom + 8 : Math.max(8, (spec.padding.top - legendHeight) / 2);
88
101
  const items = [];
89
102
  rows.forEach((row, rowIndex) => {
90
103
  let x = Math.max(spec.padding.left, spec.width - spec.padding.right - row.width);
@@ -92,7 +105,7 @@ function chromeLayout(spec, entries = []) {
92
105
  row.items.forEach(item => { items.push({ ...item, x, y, swatchSize }); x += item.width; });
93
106
  });
94
107
  const bottom = top + rows.length * lineHeight;
95
- return { title, legend: { visible: true, items, bottom, truncatedCount: measured.filter(item => item.truncated).length }, bottom: Math.max(title.bottom, bottom) };
108
+ return { title, legend: { visible: true, items, position: requestedPosition, top, bottom, left: spec.padding.left, right: spec.width - spec.padding.right, width: availableWidth, height: legendHeight, truncatedCount: measured.filter(item => item.truncated).length }, bottom: requestedPosition === 'bottom' ? title.bottom : Math.max(title.bottom, bottom) };
96
109
  }
97
110
 
98
111
  function styledSpec(spec) {
@@ -100,7 +113,7 @@ function styledSpec(spec) {
100
113
  return { ...spec, theme, colors: spec.colors || theme.colors, background: spec.background || theme.background, padding: spec.padding || theme.layout.padding };
101
114
  }
102
115
 
103
- function layout(spec, data, legendEntries = []) {
116
+ function layout(spec, data, legendEntries = [], layoutOptions = {}) {
104
117
  const p = spec.padding, width = spec.width, height = spec.height;
105
118
  const chrome = chromeLayout(spec, legendEntries);
106
119
  const xEncoding = spec.encoding.x || { field: 'name', type: 'category' };
@@ -140,29 +153,43 @@ function layout(spec, data, legendEntries = []) {
140
153
  const matrixYField = spec.encoding.y?.field || 'y', matrixTexts = spec.type === 'heatmap' ? data.rows.map(row => String(row[matrixYField] ?? '')) : [];
141
154
  const leftTexts = spec.type === 'bar' ? categoryTexts : yTicks.map(value => formatValue(value, spec.yAxis?.format, spec.locale));
142
155
  const leftLabelWidth = maxWidth(leftTexts), rightLabelWidth = yEncodings.length > 1 && !stackMode ? maxWidth(rightTicks.map(value => formatValue(value, spec.yAxis?.right?.format, spec.locale))) : 0;
143
- const centeredPlot = ['pie', 'funnel', 'gauge', 'radar'].includes(spec.type);
156
+ const axisFree = axisFreeTypes.has(spec.type);
144
157
  const sideLabelWidth = Math.max(spec.type === 'bar' ? leftLabelWidth : 0, spec.type === 'heatmap' ? maxWidth(matrixTexts) : 0);
145
- const leftTitleBand = spec.yAxis?.title && spec.type !== 'bar' ? axisSize + 16 : 0;
158
+ const leftTitleBand = spec.yAxis?.title ? axisSize + 16 : 0;
146
159
  const rightTitleBand = spec.yAxis?.right?.title && yEncodings.length > 1 && !stackMode ? axisSize + 16 : 0;
147
160
  const cartesianLeft = 8 + leftTitleBand + leftLabelWidth + 10;
148
- const plotLeft = centeredPlot
149
- ? Math.max(p.left, p.right)
150
- : ['bar', 'heatmap'].includes(spec.type)
151
- ? Math.min(width * 0.35, Math.max(p.left, sideLabelWidth + 24))
152
- : Math.max(p.left, cartesianLeft);
153
- const plotRight = centeredPlot ? plotLeft : Math.max(p.right, rightLabelWidth ? rightLabelWidth + rightTitleBand + 18 : 0);
161
+ const plotLeft = axisFree
162
+ ? Math.max(p.left, chrome.legend.position === 'left' ? chrome.legend.right + 12 : p.left)
163
+ : spec.type === 'bar'
164
+ ? Math.max(p.left, spec.yAxis?.title ? cartesianLeft : sideLabelWidth + 24)
165
+ : spec.type === 'heatmap'
166
+ ? Math.min(width * 0.35, Math.max(p.left, sideLabelWidth + 24))
167
+ : Math.max(p.left, cartesianLeft);
168
+ const plotRight = axisFree
169
+ ? Math.max(p.right, chrome.legend.position === 'right' ? spec.width - chrome.legend.left + 12 : p.right)
170
+ : Math.max(p.right, rightLabelWidth ? rightLabelWidth + rightTitleBand + 18 : 0);
154
171
  const labelTopReserve = spec.type === 'column' && spec.labels?.enabled ? Math.ceil(fontSize(spec, 'label', 12) * 1.5 + 4) : 0;
155
- const barTitleReserve = spec.type === 'bar' && spec.yAxis?.title ? axisSize + 8 : 0;
156
- const plotTop = Math.max(p.top, chrome.bottom + (chrome.bottom ? 12 : 0)) + labelTopReserve + barTitleReserve;
157
172
  const plotWidth = Math.max(1, width - plotLeft - plotRight);
158
173
  const axisLabelTexts = temporal ? timeTicks(xMin, xMax, 5).map(value => formatTime(value, xMax - xMin, spec.xAxis?.format, spec.locale)) : quantitativeX ? timeTicks(xMin, xMax, 5).map(value => formatValue(value, spec.xAxis?.format, spec.locale)) : categories;
159
174
  const xLabels = axisLabelLayout(spec, spec.type === 'bar' ? [] : axisLabelTexts, plotWidth);
175
+ const topChrome = chrome.legend.position === 'top' && chrome.legend.visible ? chrome.legend.bottom + 12 : Math.max(p.top, chrome.title.bottom + (chrome.title.bottom ? 12 : 0));
176
+ const axisFreeBottomPadding = Number(layoutOptions.axisFreeBottomPadding ?? p.bottom);
177
+ const bottomChrome = chrome.legend.position === 'bottom' && chrome.legend.visible ? chrome.legend.top - 12 : height - axisFreeBottomPadding;
178
+ const plotTop = axisFree ? Math.max(p.top, topChrome) + labelTopReserve : Math.max(p.top, chrome.bottom + (chrome.bottom ? 12 : 0)) + labelTopReserve;
160
179
  const bottomReserve = Math.max(p.bottom, Math.ceil(16 + xLabels.projectedHeight + (spec.xAxis?.title ? 20 : 0)));
161
- const plot = { x: plotLeft, y: plotTop, width: plotWidth, height: Math.max(1, height - plotTop - bottomReserve) };
180
+ const plotBottom = axisFree ? Math.min(height - axisFreeBottomPadding, bottomChrome) : height - bottomReserve;
181
+ const plot = { x: plotLeft, y: plotTop, width: plotWidth, height: Math.max(1, plotBottom - plotTop) };
182
+ const axisTitles = {
183
+ y: spec.yAxis?.title ? truncateText(spec.yAxis.title, plot.height, axisSize) : null,
184
+ right: spec.yAxis?.right?.title && yEncodings.length > 1 && !stackMode ? truncateText(spec.yAxis.right.title, plot.height, axisSize) : null
185
+ };
186
+ const axisTitleWarnings = [];
187
+ if (spec.yAxis?.title && axisTitles.y !== String(spec.yAxis.title)) axisTitleWarnings.push({ path: 'yAxis.title', title: String(spec.yAxis.title), availableWidth: plot.height });
188
+ if (spec.yAxis?.right?.title && axisTitles.right !== String(spec.yAxis.right.title) && yEncodings.length > 1 && !stackMode) axisTitleWarnings.push({ path: 'yAxis.right.title', title: String(spec.yAxis.right.title), availableWidth: plot.height });
162
189
  const rightTransform = value => rightType === 'log' ? Math.log10(Math.max(0.000001, Number(value))) : Number(value);
163
190
  const yRight = value => plot.y + plot.height - ((rightTransform(value) - rightTransform(rightMin)) / (rightTransform(rightMax) - rightTransform(rightMin) || 1)) * plot.height;
164
191
  const axisInfo = (rawDomain, domain, ticks, axis) => ({ rawDomain, domain, ticks, step: ticks.length > 1 ? (ticks[1] - ticks[0]) : null, policy: validDomain(axis?.domain) ? 'explicit' : axis?.nice === false ? 'raw' : 'nice' });
165
- return { plot, chrome, xLabels, axisLayout: { size: axisSize, leftLabelWidth, rightLabelWidth }, compact: width < 360 || height < 240, recommendedSize: { minWidth: 280, minHeight: 220 }, xField, xEncoding, temporal, quantitativeX, xMin, xMax, yField, yEncodings, categories, rawMin, rawMax, min, max, rawRightMin, rawRightMax, rightMin, rightMax, yTicks, rightTicks, axes: { y: axisInfo([rawMin, rawMax], [min, max], yTicks, spec.yAxis || {}), right: axisInfo([rawRightMin, rawRightMax], [rightMin, rightMax], rightTicks, spec.yAxis?.right || {}) }, x, y, yRight, axisType, rightType };
192
+ return { plot, chrome, layoutFamily: axisFree ? spec.type : 'cartesian', xLabels, axisTitles, axisTitleWarnings, axisLayout: { size: axisSize, leftLabelWidth, rightLabelWidth }, compact: width < 360 || height < 240, recommendedSize: { minWidth: 280, minHeight: 220 }, xField, xEncoding, temporal, quantitativeX, xMin, xMax, yField, yEncodings, categories, rawMin, rawMax, min, max, rawRightMin, rawRightMax, rightMin, rightMax, yTicks, rightTicks, axes: { y: axisInfo([rawMin, rawMax], [min, max], yTicks, spec.yAxis || {}), right: axisInfo([rawRightMin, rawRightMax], [rightMin, rightMax], rightTicks, spec.yAxis?.right || {}) }, x, y, yRight, axisType, rightType };
166
193
  }
167
194
 
168
195
  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 })); }
@@ -178,7 +205,7 @@ function addAxes(scene, spec, state) {
178
205
  ticks.forEach(value => addText(scene, `label-x-value-${value}`, formatValue(value, spec.xAxis?.format || spec.yAxis?.format, spec.locale), numericX(value), plot.y + plot.height + 22, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'middle' }));
179
206
  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' }));
180
207
  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' });
181
- if (spec.yAxis?.title) addText(scene, 'axis-y-title', truncateText(spec.yAxis.title, Math.max(axisSize, plot.x - 16), axisSize), 8, plot.y - 8, { fill: spec.theme.text, font: font(spec, 'axis'), textAnchor: 'start' });
208
+ if (state.axisTitles.y) addText(scene, 'axis-y-title', state.axisTitles.y, 8 + axisSize / 2, plot.y + plot.height / 2, { fill: spec.theme.text, font: font(spec, 'axis'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle', rotation: -90 });
182
209
  return;
183
210
  }
184
211
  const labels = state.temporal || state.quantitativeX ? timeTicks(state.xMin, state.xMax, 5) : categories;
@@ -191,8 +218,8 @@ function addAxes(scene, spec, state) {
191
218
  yTicks.forEach(value => addText(scene, `label-y-${value}`, formatValue(value, spec.yAxis?.format, spec.locale), plot.x - 10, y(value) + 4, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'end' }));
192
219
  if (state.yEncodings.length > 1 && !spec.stack) state.rightTicks.forEach(value => addText(scene, `label-y-right-${value}`, formatValue(value, spec.yAxis?.right?.format, spec.locale), plot.x + plot.width + 10, yRight(value) + 4, { fill: spec.theme.muted, font: font(spec, 'axis') }));
193
220
  if (spec.xAxis?.title) addText(scene, 'axis-x-title', spec.xAxis.title, plot.x + plot.width / 2, plot.y + plot.height + (state.xLabels.rotation ? state.xLabels.projectedHeight + 20 : 42), { fill: spec.theme.text, font: font(spec, 'axis'), textAnchor: 'middle' });
194
- if (spec.yAxis?.title) addText(scene, 'axis-y-title', truncateText(spec.yAxis.title, plot.height, axisSize), 8 + axisSize / 2, plot.y + plot.height / 2, { fill: spec.theme.text, font: font(spec, 'axis'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle', rotation: -90 });
195
- if (state.yEncodings.length > 1 && !spec.stack && spec.yAxis?.right?.title) addText(scene, 'axis-y-right-title', truncateText(spec.yAxis.right.title, plot.height, axisSize), spec.width - 8 - axisSize / 2, plot.y + plot.height / 2, { fill: spec.theme.text, font: font(spec, 'axis'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle', rotation: 90 });
221
+ if (state.axisTitles.y) addText(scene, 'axis-y-title', state.axisTitles.y, 8 + axisSize / 2, plot.y + plot.height / 2, { fill: spec.theme.text, font: font(spec, 'axis'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle', rotation: -90 });
222
+ if (state.axisTitles.right) addText(scene, 'axis-y-right-title', state.axisTitles.right, spec.width - 8 - axisSize / 2, plot.y + plot.height / 2, { fill: spec.theme.text, font: font(spec, 'axis'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle', rotation: 90 });
196
223
  }
197
224
 
198
225
  function addTitles(scene, spec, positions) {
@@ -262,11 +289,11 @@ function addHeatmapScene(scene, spec, data, state, colors) {
262
289
  }
263
290
 
264
291
  function addRadarScene(scene, spec, data, state, colors) {
265
- 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;
292
+ 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) * .44;
266
293
  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.' });
267
294
  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 }; };
268
295
  [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 } })));
269
- const labelSize = fontSize(spec, 'axis', 12), occupied = [], labelArea = { x: 4, y: Math.max(4, state.chrome.bottom + 4), width: spec.width - 8, height: Math.max(1, spec.height - spec.padding.bottom - Math.max(4, state.chrome.bottom + 4)) };
296
+ const labelSize = fontSize(spec, 'axis', 12), occupied = [], labelTop = Math.max(4, state.plot.y - labelSize * 2), labelArea = { x: 4, y: labelTop, width: spec.width - 8, height: Math.max(1, state.plot.y + state.plot.height - labelTop) };
270
297
  let hiddenLabels = 0;
271
298
  indicators.forEach((indicator, index) => {
272
299
  const edge = point(indicator, index), angle = -Math.PI / 2 + index * Math.PI * 2 / indicators.length;
@@ -356,6 +383,9 @@ export function buildScene(spec) {
356
383
  bottom: Number(baseSpec.padding?.bottom ?? 48) + (brandingEnabled ? 18 : 0),
357
384
  left: Number(baseSpec.padding?.left ?? 56),
358
385
  };
386
+ const themePadding = baseSpec.theme.layout.padding || {};
387
+ const customPadding = ['top', 'right', 'bottom', 'left'].some(key => Number(baseSpec.padding?.[key]) !== Number(themePadding[key]));
388
+ const axisFreeBottomPadding = customPadding ? effectivePadding.bottom : Math.max(28, Math.min(34, Number(themePadding.bottom ?? effectivePadding.bottom)));
359
389
  spec = { ...baseSpec, padding: effectivePadding };
360
390
  const isProjectFamily = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type);
361
391
  if (isProjectFamily) {
@@ -373,8 +403,9 @@ export function buildScene(spec) {
373
403
  const colors = spec.colors;
374
404
  const series = Array.isArray(spec.encoding.y) ? spec.encoding.y : [{ ...spec.encoding.y, name: spec.encoding.y?.name || spec.encoding.y?.field }];
375
405
  const entries = legendEntries(spec, data, series, colors);
376
- const state = layout(spec, data, entries);
406
+ const state = layout(spec, data, entries, { axisFreeBottomPadding });
377
407
  if (state.chrome.legend.truncatedCount) data.warnings.push({ code: 'LEGEND_OVERFLOW', path: 'legend', count: state.chrome.legend.truncatedCount, message: `${state.chrome.legend.truncatedCount} legend label${state.chrome.legend.truncatedCount === 1 ? '' : 's'} exceeded the available width and was truncated.`, suggestion: 'Use shorter category names or a wider chart.' });
408
+ state.axisTitleWarnings.forEach(({ path, title, availableWidth }) => data.warnings.push({ code: 'TITLE_TRUNCATED', path, availableWidth, message: `Axis title "${title}" exceeded the available vertical plot space and was truncated.`, suggestion: 'Use a taller chart or a shorter axis title.' }));
378
409
  scene._labelArea = state.plot;
379
410
  addTitles(scene, spec, state.chrome.title);
380
411
  if (!['pie', 'funnel', 'gauge', 'heatmap', 'radar'].includes(spec.type)) addAxes(scene, spec, state);
@@ -393,7 +424,7 @@ export function buildScene(spec) {
393
424
  } else if (spec.type === 'scatter') {
394
425
  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, state.plot, { x: geometry.cx, y: geometry.cy, radius: 5 }); });
395
426
  } else if (spec.type === 'pie') {
396
- const categoryField = spec.encoding.category?.field || 'name', valueField = spec.encoding.value?.field || 'value', values = data.rows.map(row => Number(row[valueField])), total = values.reduce((sum, value) => sum + (Number.isFinite(value) && value > 0 ? value : 0), 0), negativeCount = values.filter(value => Number.isFinite(value) && value < 0).length, 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) * 0.38, innerR = radius * Number(spec.innerRadius || 0); let angle = -Math.PI / 2;
427
+ const categoryField = spec.encoding.category?.field || 'name', valueField = spec.encoding.value?.field || 'value', values = data.rows.map(row => Number(row[valueField])), total = values.reduce((sum, value) => sum + (Number.isFinite(value) && value > 0 ? value : 0), 0), negativeCount = values.filter(value => Number.isFinite(value) && value < 0).length, 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) * 0.46, innerR = radius * Number(spec.innerRadius || 0); let angle = -Math.PI / 2;
397
428
  if (negativeCount) data.warnings.push({ code: 'NEGATIVE_VALUE_DROPPED', path: 'encoding.value', count: negativeCount, message: `Pie ignores ${negativeCount} negative value${negativeCount === 1 ? '' : 's'} when calculating shares.`, suggestion: 'Use non-negative part-to-whole values or choose a Cartesian chart for signed measures.' });
398
429
  if (!(total > 0)) { data.warnings.push({ code: 'ZERO_TOTAL', path: 'encoding.value', message: 'Pie requires a positive total.', suggestion: 'Provide at least one positive value or render an explicit empty state.' }); addText(scene, 'pie-zero-total', spec.emptyText || 'No positive values', cx, cy, { fill: spec.theme.muted, font: font(spec, 'subtitle'), textAnchor: 'middle' }); }
399
430
  else data.rows.forEach((row, index) => { const rawValue = values[index], value = Number.isFinite(rawValue) ? Math.max(0, rawValue) : 0, end = angle + value / total * Math.PI * 2, middle = angle + (end - angle) / 2, fill = colors[index % colors.length], dataRef = { seriesIndex: 0, dataIndex: index, category: row[categoryField], value, rawValue }; 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, 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, state.plot, null, spec.labels?.format || { style: 'percent' }, fill); angle = end; });
@@ -401,7 +432,7 @@ export function buildScene(spec) {
401
432
  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;
402
433
  data.rows.forEach((row, index) => { const value = Number(row[valueField]) || 0, ratio = Math.max(0.1, value / maxValue), fill = colors[index % colors.length]; 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 }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 + 4, dataRef, state.plot, null, null, fill); });
403
434
  } else if (spec.type === 'gauge') {
404
- const valueField = spec.encoding.value?.field || 'value', domain = Array.isArray(spec.domain) ? spec.domain : [0, 100], rawValue = Number(data.rows[0]?.[valueField]), numericValue = Number.isFinite(rawValue) ? rawValue : 0, value = Math.max(Number(domain[0]), Math.min(Number(domain[1]), numericValue)), ratio = (value - Number(domain[0])) / (Number(domain[1]) - Number(domain[0]) || 1), cx = state.plot.x + state.plot.width / 2, cy = state.plot.y + state.plot.height * 0.62, radius = Math.min(state.plot.width, state.plot.height) * 0.36, start = Math.PI, end = start + Math.PI * ratio;
435
+ const valueField = spec.encoding.value?.field || 'value', domain = Array.isArray(spec.domain) ? spec.domain : [0, 100], rawValue = Number(data.rows[0]?.[valueField]), numericValue = Number.isFinite(rawValue) ? rawValue : 0, value = Math.max(Number(domain[0]), Math.min(Number(domain[1]), numericValue)), ratio = (value - Number(domain[0])) / (Number(domain[1]) - Number(domain[0]) || 1), cx = state.plot.x + state.plot.width / 2, cy = state.plot.y + state.plot.height * 0.68, radius = Math.min(state.plot.width * 0.42, state.plot.height * 0.62), start = Math.PI, end = start + Math.PI * ratio;
405
436
  if (!Array.isArray(spec.domain)) data.warnings.push({ code: 'MISSING_GAUGE_DOMAIN', path: 'domain', message: 'Gauge rendered with a compatibility fallback; provide an explicit domain to make the value meaningful.' });
406
437
  if (numericValue < Number(domain[0]) || numericValue > Number(domain[1])) data.warnings.push({ code: 'VALUE_CLAMPED', path: `data.values[0].${valueField}`, count: 1, rawValue: numericValue, domain: [...domain], message: `Gauge value ${numericValue} is outside domain [${domain[0]}, ${domain[1]}] and was clamped for the arc.`, suggestion: 'Choose a domain that covers the value or review the source unit.' });
407
438
  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, rawValue: numericValue }, interactive: true })); addText(scene, 'gauge-label', formatValue(numericValue, spec.labels?.format || spec.encoding.value?.format || { maximumFractionDigits: 2 }, spec.locale), cx, cy - 12, { fill: spec.theme.text, font: font(spec, 'metric'), textAnchor: 'middle' });
package/src/command.mjs CHANGED
@@ -3,9 +3,10 @@
3
3
  * Commands are JSON-safe, stable-ID based, and do not mutate input data.
4
4
  */
5
5
  import { copyJSON, issue } from './schema.mjs';
6
+ import { commandTypes } from './contract-registry.mjs';
6
7
 
7
8
  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', 'removeEdge', 'toggleGroupCollapse', 'addEdge', 'duplicateSelection', 'pasteSelection'];
9
+ export const operationTypes = [...commandTypes];
9
10
 
10
11
  export function normalizeCommand(input) {
11
12
  const command = copyJSON(input);
@@ -63,7 +64,7 @@ export function commandCapabilities(schema) {
63
64
  const fields = schema?.fields || {};
64
65
  const can = name => fields[name]?.editable !== false && fields[name]?.agentEditable !== false;
65
66
  const flowNode = ['flow-node', 'architecture-node', 'mindmap-node'].includes(schema?.name);
66
- const flowEdge = ['flow-edge', 'architecture-edge'].includes(schema?.name);
67
+ const flowEdge = ['flow-edge', 'architecture-edge', 'mindmap-edge'].includes(schema?.name);
67
68
  return {
68
69
  updateField: Object.keys(fields).filter(can),
69
70
  updateRecord: true,
@@ -0,0 +1,59 @@
1
+ /**
2
+ * JSON-safe public contract metadata shared by runtime discovery and release checks.
3
+ * Keep this module dependency-free so browser consumers can import the registry safely.
4
+ */
5
+ export const contractVersion = '1.1';
6
+ export const chartTypes = ['line', 'area', 'bar', 'column', 'pie', 'scatter', 'funnel', 'gauge', 'heatmap', 'radar', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'];
7
+ export const renderers = ['canvas', 'svg'];
8
+ export const exportTypes = ['png', 'jpeg', 'svg', 'json'];
9
+ export const interactionDefaults = { zoom: false, pan: false, brush: false, drag: false, edgeDrag: false, portConnect: false, editing: false };
10
+ export const commandTypes = ['updateField', 'updateRecord', 'updateTask', 'shiftTask', 'updateProgress', 'addDependency', 'removeDependency', 'updateMilestone', 'moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'removeEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'];
11
+ export const diagramOperations = ['moveNode', 'moveNodes', 'resizeNode', 'alignNodes', 'snapNodes', 'moveNodeToLane', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'removeEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'];
12
+ export const businessModels = ['project-task', 'timeline-event', 'milestone', 'burndown-sample', 'flow-node', 'flow-edge', 'swimlane', 'architecture-node', 'architecture-edge', 'mindmap-edge', 'mindmap-node'];
13
+ export const diagramEdgeModels = ['flow-edge', 'architecture-edge', 'mindmap-edge'];
14
+
15
+ const status = (supported, notApplicable = []) => Object.fromEntries(supported.map(name => [name, 'supported']).concat(notApplicable.map(name => [name, 'not-applicable'])));
16
+ const commonPresentation = ['title', 'subtitle', 'theme', 'responsive', 'empty-state', 'invalid-data-state', 'export', 'branding'];
17
+
18
+ export const chartDefinitions = {
19
+ 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']) },
20
+ 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']) },
21
+ 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']) },
22
+ 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']) },
23
+ 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']) },
24
+ 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']) },
25
+ funnel: { family: 'stage', intents: ['funnel', 'conversion'], required: ['dimension', 'measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting'], ['axes', 'grid', 'zoom', 'pan']) },
26
+ gauge: { family: 'indicator', intents: ['progress', 'single-value'], required: ['measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting', 'domain'], ['axes', 'grid', 'legend', 'zoom', 'pan']) },
27
+ 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']) },
28
+ 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']) },
29
+ 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']) },
30
+ timeline: { family: 'project', intents: ['timeline', 'milestone'], required: ['date', 'label'], optional: ['status'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting']) },
31
+ 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']) },
32
+ 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']) },
33
+ flow: { family: 'diagram', intents: ['workflow'], required: ['nodes'], optional: ['edges', 'groups', 'ports'], interactions: ['hover', 'tooltip', 'selection', 'edge-selection', 'drag', 'edgeDrag', 'zoom', 'pan', 'keyboard', 'keyboard-edit', 'keyboard-port-connect', 'portConnect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'manual-routing', 'layout', 'groups', 'ports', 'history'], ['axes', 'grid', 'legend']) },
34
+ swimlane: { family: 'diagram', intents: ['responsibility'], required: ['nodes', 'lanes'], optional: ['edges', 'groups', 'ports'], interactions: ['hover', 'tooltip', 'selection', 'edge-selection', 'drag', 'edgeDrag', 'zoom', 'pan', 'keyboard', 'keyboard-edit', 'keyboard-port-connect', 'portConnect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'manual-routing', 'layout', 'groups', 'ports', 'lanes', 'history'], ['axes', 'grid', 'legend']) },
35
+ architecture: { family: 'diagram', intents: ['architecture', 'business-architecture', 'data-architecture', 'technical-architecture'], required: ['nodes'], optional: ['edges', 'layers', 'boundaries', 'groups', 'ports'], interactions: ['hover', 'tooltip', 'selection', 'edge-selection', 'drag', 'edgeDrag', 'zoom', 'pan', 'keyboard', 'keyboard-edit', 'keyboard-port-connect', 'portConnect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'manual-routing', 'layout', 'layers', 'boundaries', 'groups', 'ports', 'history'], ['axes', 'grid', 'legend']) },
36
+ mindmap: { family: 'diagram', intents: ['mindmap', 'hierarchy', 'brainstorm'], required: ['nodes'], optional: ['parentId', 'edges', 'groups'], interactions: ['hover', 'tooltip', 'selection', 'edge-selection', 'drag', 'edgeDrag', 'zoom', 'pan', 'keyboard', 'keyboard-edit', 'keyboard-port-connect', 'portConnect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'cubic-bezier', 'curve-tension', 'obstacle-fallback', 'manual-routing', 'layout', 'tree-layout', 'radial-layout', 'history'], ['axes', 'grid', 'legend']) }
37
+ };
38
+
39
+ export const chartProfiles = Object.fromEntries(chartTypes.map(type => [type, {
40
+ type,
41
+ ...chartDefinitions[type],
42
+ dataShapes: chartDefinitions[type].family === 'diagram' ? ['diagram-spec'] : chartDefinitions[type].family === 'project' ? ['records'] : ['records', 'data-table'],
43
+ renderers: [...renderers],
44
+ exports: [...exportTypes],
45
+ limits: type === 'pie' ? { recommendedCategories: 8 } : type === 'radar' ? { minimumIndicators: 3, recommendedIndicators: 8 } : type === 'heatmap' ? { recommendedCells: 2500 } : {}
46
+ }]));
47
+
48
+ export const contractRegistry = Object.freeze({
49
+ contractVersion,
50
+ chartTypes: [...chartTypes],
51
+ chartProfiles: JSON.parse(JSON.stringify(chartProfiles)),
52
+ renderers: [...renderers],
53
+ exportTypes: [...exportTypes],
54
+ interactionDefaults: { ...interactionDefaults },
55
+ commandTypes: [...commandTypes],
56
+ diagramOperations: [...diagramOperations],
57
+ businessModels: [...businessModels],
58
+ diagramEdgeModels: [...diagramEdgeModels]
59
+ });
@@ -60,7 +60,7 @@ export class EditController {
60
60
  edgeSchema: spec.data.edgeSchema,
61
61
  type: spec.type,
62
62
  nodeModel: spec.type === 'architecture' ? 'architecture-node' : spec.type === 'mindmap' ? 'mindmap-node' : 'flow-node',
63
- edgeModel: spec.type === 'architecture' ? 'architecture-edge' : 'flow-edge',
63
+ edgeModel: spec.type === 'architecture' ? 'architecture-edge' : spec.type === 'mindmap' ? 'mindmap-edge' : 'flow-edge',
64
64
  validationOptions: { ...spec.validationOptions, references: { ...spec.validationOptions?.references, ...(diagram ? { 'flow-node': nodes.map(row => row.id), swimlane: lanes.map(row => row.id) } : {}) } },
65
65
  requireConfirmation: spec.editing?.requireConfirmation,
66
66
  grid: spec.diagram?.grid || 8,
package/src/errors.mjs ADDED
@@ -0,0 +1,31 @@
1
+ /** Structured public runtime errors for failed chart mutations. */
2
+ export class ChartValidationError extends Error {
3
+ constructor(operation, diagnostics = [], message = null) {
4
+ const details = Array.isArray(diagnostics) ? diagnostics : [];
5
+ const first = details[0] || {};
6
+ super(message || details.map(item => item.message).filter(Boolean).join(' ') || `Invalid ${operation} mutation.`);
7
+ this.name = 'ChartValidationError';
8
+ this.code = 'CHART_VALIDATION_FAILED';
9
+ this.operation = operation;
10
+ this.details = details;
11
+ this.errors = details;
12
+ this.path = first.path;
13
+ this.expected = first.expected;
14
+ this.received = first.received;
15
+ this.suggestion = first.suggestion;
16
+ }
17
+
18
+ toJSON() {
19
+ return {
20
+ name: this.name,
21
+ code: this.code,
22
+ operation: this.operation,
23
+ message: this.message,
24
+ details: this.details,
25
+ ...(this.path === undefined ? {} : { path: this.path }),
26
+ ...(this.expected === undefined ? {} : { expected: this.expected }),
27
+ ...(this.received === undefined ? {} : { received: this.received }),
28
+ ...(this.suggestion === undefined ? {} : { suggestion: this.suggestion })
29
+ };
30
+ }
31
+ }
package/src/index.mjs CHANGED
@@ -22,6 +22,9 @@ import { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, cr
22
22
  import { explainChart, getCapabilities as discoverCapabilities, getChartCapability, getPreferenceCapabilities, planChart } from './capabilities.mjs';
23
23
  import { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, normalizePreferences, validatePreferences } from './preferences.mjs';
24
24
  import { mountChartSettings } from './preferences-ui.mjs';
25
+ import { ChartValidationError } from './errors.mjs';
26
+ import { diagramOperations } from './contract-registry.mjs';
27
+ import { destroyChart } from './chart-lifecycle.mjs';
25
28
 
26
29
  import { isDiagram, paintSelection, diagramPointer, diagramKeyboard } from './diagram-interaction.mjs';
27
30
 
@@ -189,7 +192,7 @@ export class Chart {
189
192
  delete specInput.preferencesStore;
190
193
  delete specInput.chartId;
191
194
  const result = validateSpec(specInput);
192
- if (!result.valid) { const error = new Error(result.errors.map(item => item.message).join(' ')); error.details = result.errors; throw error; }
195
+ if (!result.valid) throw new ChartValidationError('create', result.errors);
193
196
  this._specDiagnostics = { warnings: result.warnings, normalizations: result.normalizations };
194
197
  this.chartId = input.chartId ?? input.id ?? null;
195
198
  this._preferencesStore = isPreferencesStore(input.preferencesStore) ? input.preferencesStore : isPreferencesStore(input.preferences) ? input.preferences : null;
@@ -209,6 +212,7 @@ export class Chart {
209
212
  this._history = new EditHistory();
210
213
  this._revision = 0;
211
214
  this._lastChangeSet = null;
215
+ this._destroyed = false;
212
216
  this._editor = new EditController(this);
213
217
  this.plugins = new PluginHost(this, this.spec.plugins);
214
218
  this._mountRenderer();
@@ -332,16 +336,114 @@ export class Chart {
332
336
  _updateCrosshair(x, y) { const vertical = this.model.scene.find('crosshair-x'), horizontal = this.model.scene.find('crosshair-y'); if (!vertical || !horizontal) return; vertical.geometry.x1 = vertical.geometry.x2 = x; vertical.style.opacity = 0.7; horizontal.geometry.y1 = horizontal.geometry.y2 = y; horizontal.style.opacity = 0.7; this.renderer.render(this.model.scene); }
333
337
  _showTooltip(payload) { if (typeof document === 'undefined' || !this.container || !payload.datum) return; let tip = this._tooltip; if (!tip) { tip = this._tooltip = document.createElement('div'); tip.className = 'ichart-v2-tooltip'; Object.assign(tip.style, { position: 'fixed', pointerEvents: 'none', zIndex: 9999, padding: '8px 10px', borderRadius: '6px', maxWidth: 'min(320px, calc(100vw - 24px))', whiteSpace: 'pre-line' }); document.body.appendChild(tip); } Object.assign(tip.style, { background: this.spec.theme.surface, color: this.spec.theme.text, border: `1px solid ${this.spec.theme.border}`, font: this.spec.theme.typography.tooltip.font, boxShadow: `0 4px 12px ${this.spec.theme.border}88` }); const text = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(this.spec.type) ? projectTooltip(this.spec.type, payload.datum) : Object.entries(payload.datum).map(([key, value]) => `${key}: ${value}`).join(' · '); tip.textContent = text; const left = Math.min((payload.nativeEvent.clientX || 0) + 12, window.innerWidth - tip.offsetWidth - 12); const top = Math.min((payload.nativeEvent.clientY || 0) + 12, window.innerHeight - tip.offsetHeight - 12); tip.style.left = `${Math.max(12, left)}px`; tip.style.top = `${Math.max(12, top)}px`; }
334
338
  _hideTooltip() { if (this._tooltip) this._tooltip.style.left = '-10000px'; }
339
+ _validateMutation(operation, input) {
340
+ const result = validateSpec(input);
341
+ if (!result.valid) throw new ChartValidationError(operation, result.errors);
342
+ return result;
343
+ }
344
+ _snapshotMutation() {
345
+ return {
346
+ spec: this.spec,
347
+ model: this.model,
348
+ themeInput: clone(this._themeInput),
349
+ styleOverrides: clone(this._styleOverrides),
350
+ preferenceBase: clone(this._preferenceBase),
351
+ localPreferences: clone(this._localPreferences),
352
+ preferencesSnapshot: this._preferencesSnapshot,
353
+ specDiagnostics: clone(this._specDiagnostics)
354
+ };
355
+ }
356
+ _restoreMutation(snapshot) {
357
+ this.spec = snapshot.spec;
358
+ this.model = snapshot.model;
359
+ this._themeInput = snapshot.themeInput;
360
+ this._styleOverrides = snapshot.styleOverrides;
361
+ this._preferenceBase = snapshot.preferenceBase;
362
+ this._localPreferences = snapshot.localPreferences;
363
+ this._preferencesSnapshot = snapshot.preferencesSnapshot;
364
+ this._specDiagnostics = snapshot.specDiagnostics;
365
+ if (this.renderer) this.renderer.options = this.spec;
366
+ try { if (this.renderer?.container && this.model?.scene) this.renderer.render(this.model.scene); } catch {}
367
+ }
368
+ _commitMutation() {
369
+ const previousRevision = this._revision;
370
+ this._revision += 1;
371
+ try {
372
+ this.render();
373
+ this._history.clear();
374
+ this._lastChangeSet = null;
375
+ this._editor.pending.clear();
376
+ return this;
377
+ } catch (error) {
378
+ this._revision = previousRevision;
379
+ throw error;
380
+ }
381
+ }
335
382
  on(type, listener) { if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type).add(listener); return this; }
336
383
  off(type, listener) { this.listeners.get(type)?.delete(listener); return this; }
337
384
  emit(type, event) { this.listeners.get(type)?.forEach(listener => listener(event)); }
338
- update(next = {}) { this._editor.invalidate(); const { preferences, preferencesStore, chartId, ...specUpdate } = next || {}; if (preferences !== undefined) this.setPreferences(preferences); if (specUpdate.theme !== undefined) this._themeInput = specUpdate.theme; ['colors', 'background', 'padding', 'legend', 'labels', 'grid', 'branding'].forEach(key => { if (specUpdate[key] !== undefined) this._styleOverrides[key] = true; }); this.spec = normalizeSpec({ ...this.spec, ...specUpdate, theme: this._themeInput, data: specUpdate.data === undefined ? this.spec.data : specUpdate.data }); ['legend', 'labels', 'grid', 'branding', 'padding'].forEach(key => { if (specUpdate[key] !== undefined) this._preferenceBase[key] = clone(this.spec[key]); }); this._resolveStyle(); return this.render(); }
339
- setData(data) { this._editor.invalidate(); this.spec = normalizeSpec({ ...this.spec, theme: this._themeInput, data: { values: data } }); this._resolveStyle(); return this.render(); }
340
- setTheme(theme = 'auto') { this._themeInput = theme; this._resolveStyle(); this.render(); this.emit('themechange', { chart: this, theme: this.getTheme(), source: 'user' }); return this; }
385
+ update(next = {}) {
386
+ const { preferences, preferencesStore, chartId, preferenceScope, ...specUpdate } = next || {};
387
+ if (preferences !== undefined) {
388
+ const checked = validatePreferences(preferences, { partial: true });
389
+ if (!checked.valid) throw new ChartValidationError('update', checked.errors);
390
+ }
391
+ const themeInput = specUpdate.theme === undefined ? this._themeInput : specUpdate.theme;
392
+ const result = this._validateMutation('update', { ...this.spec, ...specUpdate, theme: themeInput, data: specUpdate.data === undefined ? this.spec.data : specUpdate.data });
393
+ const snapshot = this._snapshotMutation();
394
+ try {
395
+ this._themeInput = themeInput;
396
+ ['colors', 'background', 'padding', 'legend', 'labels', 'grid', 'branding'].forEach(key => { if (specUpdate[key] !== undefined) this._styleOverrides[key] = true; });
397
+ this.spec = result.spec;
398
+ ['legend', 'labels', 'grid', 'branding', 'padding'].forEach(key => { if (specUpdate[key] !== undefined) this._preferenceBase[key] = clone(this.spec[key]); });
399
+ this._specDiagnostics = { warnings: result.warnings, normalizations: result.normalizations };
400
+ if (preferences !== undefined) {
401
+ if (this._preferencesStore) {
402
+ const scope = preferenceScope === 'global' ? 'global' : 'chart';
403
+ if (scope === 'global') this._preferencesStore.setGlobal(preferences, { source: 'update' });
404
+ else this._preferencesStore.setChart(this.chartId || 'default', preferences, { source: 'update' });
405
+ } else this._localPreferences = mergePreferences(this._localPreferences, preferences);
406
+ }
407
+ this._resolveStyle();
408
+ return this._commitMutation();
409
+ } catch (error) {
410
+ this._restoreMutation(snapshot);
411
+ throw error;
412
+ }
413
+ }
414
+ setData(data) {
415
+ const result = this._validateMutation('setData', { ...this.spec, theme: this._themeInput, data: { values: data } });
416
+ const snapshot = this._snapshotMutation();
417
+ try {
418
+ this.spec = result.spec;
419
+ this._specDiagnostics = { warnings: result.warnings, normalizations: result.normalizations };
420
+ this._resolveStyle();
421
+ return this._commitMutation();
422
+ } catch (error) {
423
+ this._restoreMutation(snapshot);
424
+ throw error;
425
+ }
426
+ }
427
+ setTheme(theme = 'auto') {
428
+ const result = this._validateMutation('setTheme', { ...this.spec, theme });
429
+ const snapshot = this._snapshotMutation();
430
+ try {
431
+ this._themeInput = theme;
432
+ this.spec = result.spec;
433
+ this._specDiagnostics = { warnings: result.warnings, normalizations: result.normalizations };
434
+ this._resolveStyle();
435
+ this._commitMutation();
436
+ this.emit('themechange', { chart: this, theme: this.getTheme(), source: 'user' });
437
+ return this;
438
+ } catch (error) {
439
+ this._restoreMutation(snapshot);
440
+ throw error;
441
+ }
442
+ }
341
443
  getTheme() { return clone(this.spec.theme); }
342
444
  getPreferences() { return this._preferencesStore ? this._preferencesStore.getEffective(this.chartId) : clone(this._localPreferences || defaultPreferences); }
343
- setPreferences(patch = {}, options = {}) { if (this._preferencesStore) { const scope = options.scope === 'global' ? 'global' : 'chart'; if (scope === 'global') this._preferencesStore.setGlobal(patch, { ...options, source: options.source || 'user' }); else this._preferencesStore.setChart(this.chartId || 'default', patch, { ...options, source: options.source || 'user' }); return this; } this._localPreferences = mergePreferences(this._localPreferences, patch); this._preferencesSnapshot = JSON.stringify(this._localPreferences); this._resolveStyle(); this.render(); this.emit('preferenceschange', { chart: this, preferences: this.getPreferences(), source: options.source || 'user', scope: 'chart', persisted: false }); return this; }
344
- resetPreferences(options = {}) { if (this._preferencesStore) { this._preferencesStore.reset({ ...options, scope: options.scope || 'chart', chartId: this.chartId || 'default' }); return this; } this._localPreferences = normalizePreferences({}); this._preferencesSnapshot = JSON.stringify(this._localPreferences); this._resolveStyle(); this.render(); this.emit('preferenceschange', { chart: this, preferences: this.getPreferences(), source: options.source || 'user', scope: 'chart', persisted: false }); return this; }
445
+ setPreferences(patch = {}, options = {}) { const checked = validatePreferences(patch, { partial: true }); if (!checked.valid) throw new ChartValidationError('setPreferences', checked.errors); if (this._preferencesStore) { const scope = options.scope === 'global' ? 'global' : 'chart'; if (scope === 'global') this._preferencesStore.setGlobal(checked.value, { ...options, source: options.source || 'user' }); else this._preferencesStore.setChart(this.chartId || 'default', checked.value, { ...options, source: options.source || 'user' }); return this; } const snapshot = this._snapshotMutation(); try { this._localPreferences = mergePreferences(this._localPreferences, checked.value); this._preferencesSnapshot = JSON.stringify(this._localPreferences); this._resolveStyle(); this._commitMutation(); this.emit('preferenceschange', { chart: this, preferences: this.getPreferences(), source: options.source || 'user', scope: 'chart', persisted: false }); return this; } catch (error) { this._restoreMutation(snapshot); throw error; } }
446
+ resetPreferences(options = {}) { if (this._preferencesStore) { this._preferencesStore.reset({ ...options, scope: options.scope || 'chart', chartId: this.chartId || 'default' }); return this; } const snapshot = this._snapshotMutation(); try { this._localPreferences = normalizePreferences({}); this._preferencesSnapshot = JSON.stringify(this._localPreferences); this._resolveStyle(); this._commitMutation(); this.emit('preferenceschange', { chart: this, preferences: this.getPreferences(), source: options.source || 'user', scope: 'chart', persisted: false }); return this; } catch (error) { this._restoreMutation(snapshot); throw error; } }
345
447
  resize(width = this.spec.width, height = this.spec.height) { this.spec.width = width; this.spec.height = height; this.render(); this.emit('resize', { chart: this, width, height }); return this; }
346
448
  getSpec() { return JSON.parse(JSON.stringify(this.spec)); }
347
449
  _getDiagnostics() { return [...new Map([...(this._specDiagnostics?.warnings || []), ...(this.model.data?.warnings || []), ...(this.spec.theme?.warnings || [])].map(item => [`${item.code}:${item.path || ''}`, item])).values()]; }
@@ -353,13 +455,13 @@ export class Chart {
353
455
  const clampedValues = warnings.filter(item => item.code === 'VALUE_CLAMPED').reduce((sum, item) => sum + Number(item.count || 1), 0);
354
456
  return { version: '1.0', status: isEmpty ? 'empty' : warnings.length ? 'degraded' : 'ready', renderable: !isEmpty, issues: [...new Set(warnings.map(item => item.code))], metrics: { warnings: warnings.length, suppressedLabels, clampedValues, renderedMarks: marks.length } };
355
457
  }
356
- getState() { const brandingSignature = discoverCapabilities().branding.signature, warnings = this._getDiagnostics(); return { renderer: this.renderer.constructor.name, width: this.spec.width, height: this.spec.height, dataCount: this.model.data.rows.length, selected: [...this._selected.values()], revision: this._revision, history: this._history.state(), view: clone(this.spec.view || null), style: clone({ name: this.spec.theme.name, mode: this.spec.theme.mode, resolvedMode: this.spec.theme.resolvedMode, preset: this.spec.theme.preset, palette: this.spec.theme.palette, reasons: this.spec.theme.reasons }), axes: clone(this.model.state?.axes || null), health: this._getHealth(warnings), preferences: this.getPreferences(), branding: { enabled: Boolean(this.spec.branding?.enabled), signature: brandingSignature, text: this.spec.branding?.enabled === true ? brandingSignature : null }, warnings: clone(warnings), assumptions: clone(this.model.data?.assumptions || []), normalizations: clone(this._specDiagnostics?.normalizations || []), collapsedGroups: this.getCollapsedGroupIds(), clipboard: { nodes: this._clipboard?.nodes?.length || 0, edges: this._clipboard?.edges?.length || 0 }, projectAnalytics: clone(this.model.state?.projectAnalytics || null), linked: clone(this.model.state?.linked || null) }; }
458
+ getState() { const brandingSignature = discoverCapabilities().branding.signature, warnings = this._getDiagnostics(), layoutState = this.model.state?.plot ? { family: this.model.state.layoutFamily || null, plot: clone(this.model.state.plot), chrome: clone(this.model.state.chrome || null), labels: clone(this.model.state.labelLayout || null) } : null; return { renderer: this.renderer.constructor.name, width: this.spec.width, height: this.spec.height, dataCount: this.model.data.rows.length, selected: [...this._selected.values()], revision: this._revision, history: this._history.state(), view: clone(this.spec.view || null), style: clone({ name: this.spec.theme.name, mode: this.spec.theme.mode, resolvedMode: this.spec.theme.resolvedMode, preset: this.spec.theme.preset, palette: this.spec.theme.palette, reasons: this.spec.theme.reasons }), layout: layoutState, axes: clone(this.model.state?.axes || null), health: this._getHealth(warnings), preferences: this.getPreferences(), branding: { enabled: Boolean(this.spec.branding?.enabled), signature: brandingSignature, text: this.spec.branding?.enabled === true ? brandingSignature : null }, warnings: clone(warnings), assumptions: clone(this.model.data?.assumptions || []), normalizations: clone(this._specDiagnostics?.normalizations || []), collapsedGroups: this.getCollapsedGroupIds(), clipboard: { nodes: this._clipboard?.nodes?.length || 0, edges: this._clipboard?.edges?.length || 0 }, projectAnalytics: clone(this.model.state?.projectAnalytics || null), linked: clone(this.model.state?.linked || null) }; }
357
459
  getProjectAnalytics() { return clone(this.model.state?.projectAnalytics || null); }
358
460
  getLinkedState() { return clone(this.model.state?.linked || null); }
359
461
  setLinkedFilters(filters = {}) { this.spec.project = { ...(this.spec.project || {}), linked: { ...(this.spec.project?.linked || {}), filters: normalizeLinkedFilters(filters) } }; this.emit('linkedstatechange', { chart: this, linked: this.spec.project.linked }); return this.render(); }
360
462
  setLinkedSelection(selection = []) { this.spec.project = { ...(this.spec.project || {}), linked: { ...(this.spec.project?.linked || {}), selection: normalizeLinkedSelection(selection) } }; this.emit('linkedstatechange', { chart: this, linked: this.spec.project.linked }); return this.render(); }
361
463
  describe() { return { type: this.spec.type, renderer: this.renderer.constructor.name, dimensions: [this.spec.encoding.x?.field || this.spec.encoding.category?.field], measures: (Array.isArray(this.spec.encoding.y) ? this.spec.encoding.y : [this.spec.encoding.y || this.spec.encoding.value]).filter(Boolean).map(encoding => encoding.field), dataCount: this.model.data.rows.length, theme: this.spec.theme?.name || 'custom', interactions: Object.keys(this.spec.interaction || {}).filter(key => this.spec.interaction[key]) }; }
362
- explain() { const explanation = explainChart(this.spec, this.model), state = this.getState(); return { ...explanation, warnings: state.warnings, health: state.health }; }
464
+ explain() { const explanation = explainChart(this.spec, this.model), state = this.getState(); return { ...explanation, layout: state.layout, warnings: state.warnings, health: state.health }; }
363
465
  getAccessibleDescription() { const description = this.spec.accessibility?.description || this.spec.title?.text || `${this.spec.type} chart`; return `${description}; ${this.model.data.rows.length} data items.`; }
364
466
  inspectDataSchema() { return inspectDataSchema(this.spec.data.schema || this.spec.schema); }
365
467
  validateData() { return validateData(this.toDataTable(), this.spec.data.schema || this.spec.schema, this.spec.validationOptions); }
@@ -447,7 +549,36 @@ export class Chart {
447
549
  getElementAt(x, y) { return this.model.scene.hit(x, y); }
448
550
  selectBox(start, end) { const left = Math.min(start.x, end.x), right = Math.max(start.x, end.x), top = Math.min(start.y, end.y), bottom = Math.max(start.y, end.y); this._selected.clear(); this.model.scene.walk(node => { node.selected = false; if (!node.dataRef || !node.bounds || !node.interactive || (isDiagram(this) && !node.id.startsWith('node-'))) return; const intersects = node.bounds.x <= right && node.bounds.x + node.bounds.width >= left && node.bounds.y <= bottom && node.bounds.y + node.bounds.height >= top; if (intersects) { node.selected = true; this._selected.set(node.id, node.dataRef); } }); this.emit('selectionchange', { chart: this, selectedData: this.getSelectedData(), box: { start, end } }); this.render(); return this; }
449
551
  toDataTable() { return this.model.data.rows.map(row => ({ ...row })); }
450
- applyPatch(patches = []) { patches.forEach(patch => { const path = patch.path.replace(/^\//, '').split('/'); let target = this.spec; path.slice(0, -1).forEach(key => { target = target[key]; }); if (patch.op === 'remove') delete target[path.at(-1)]; else target[path.at(-1)] = patch.value; }); return this.render(); }
552
+ applyPatch(patches = []) {
553
+ const candidate = clone(this.spec);
554
+ try {
555
+ patches.forEach((patch, index) => {
556
+ if (!patch || !['add', 'replace', 'remove'].includes(patch.op) || typeof patch.path !== 'string' || !patch.path.startsWith('/')) throw new ChartValidationError('applyPatch', [{ code: 'INVALID_PATCH', path: `patches[${index}]`, message: 'Patch requires add, replace, or remove and an absolute JSON pointer path.', suggestion: 'Use for example { op: "replace", path: "/title/text", value: "Revenue" }.' }]);
557
+ const path = patch.path.replace(/^\//, '').split('/').map(key => key.replace(/~1/g, '/').replace(/~0/g, '~'));
558
+ let target = candidate;
559
+ path.slice(0, -1).forEach(key => { if (target == null || typeof target !== 'object' || !(key in target)) throw new ChartValidationError('applyPatch', [{ code: 'INVALID_PATCH_PATH', path: `patches[${index}].path`, message: `Patch path does not exist: ${patch.path}`, suggestion: 'Patch an existing Spec path or add a direct child of an existing object.' }]); target = target[key]; });
560
+ const key = path.at(-1);
561
+ if (patch.op === 'remove') delete target[key];
562
+ else target[key] = clone(patch.value);
563
+ });
564
+ } catch (error) {
565
+ if (error instanceof ChartValidationError) throw error;
566
+ throw new ChartValidationError('applyPatch', [{ code: 'INVALID_PATCH', path: 'patches', message: error.message, suggestion: 'Use JSON-safe patch values and valid Spec paths.' }]);
567
+ }
568
+ const themePatched = patches.some(patch => typeof patch?.path === 'string' && (patch.path === '/theme' || patch.path.startsWith('/theme/')));
569
+ const result = this._validateMutation('applyPatch', { ...candidate, theme: themePatched ? candidate.theme : this._themeInput });
570
+ const snapshot = this._snapshotMutation();
571
+ try {
572
+ this.spec = result.spec;
573
+ if (themePatched) this._themeInput = candidate.theme;
574
+ this._specDiagnostics = { warnings: result.warnings, normalizations: result.normalizations };
575
+ this._resolveStyle();
576
+ return this._commitMutation();
577
+ } catch (error) {
578
+ this._restoreMutation(snapshot);
579
+ throw error;
580
+ }
581
+ }
451
582
  resetZoom() { delete this.spec.view; return this.render(); }
452
583
  zoomTo(view) { this.spec.view = { ...view }; this.emit('zoomchange', { chart: this, view: this.spec.view }); return this.render(); }
453
584
  panBy(delta) { const current = this.spec.view || {}; this.spec.view = { ...current, offsetX: (current.offsetX || 0) + (delta.x || 0), offsetY: (current.offsetY || 0) + (delta.y || 0) }; this.emit('zoomchange', { chart: this, view: this.spec.view }); return this.render(); }
@@ -626,11 +757,12 @@ export class Chart {
626
757
  return { valid: false, code: 'DOWNLOAD_FAILED', message: String(err && err.message || err), suggestion: 'Try chart.export() and write the result manually.' };
627
758
  }
628
759
  }
629
- destroy() { this._preferencesUnsubscribe?.(); if (this._resizeObserver) this._resizeObserver.disconnect(); this._colorSchemeQuery?.removeEventListener?.('change', this._colorSchemeHandler); if (this._eventsBound) { const { target, handler, start, move, end, leave, touchStart, touchMove, touchEnd, wheel } = this._eventsBound; target.removeEventListener('mousemove', handler); target.removeEventListener('click', handler); target.removeEventListener('pointerdown', start); target.removeEventListener('pointermove', move); target.removeEventListener('pointerup', end); target.removeEventListener('pointercancel', end); target.removeEventListener('pointerleave', leave); target.removeEventListener('touchstart', touchStart); target.removeEventListener('touchmove', touchMove); target.removeEventListener('touchend', touchEnd); target.removeEventListener('wheel', wheel); } const target = this.renderer.svg || this.renderer.canvas; if (this._keyboardHandler) target?.removeEventListener('keydown', this._keyboardHandler); this._tooltip?.remove(); this.plugins.destroy(); this.renderer.destroy(); this.listeners.clear(); this._eventsBound = null; this._selected.clear(); this._clipboard = { nodes: [], edges: [] }; }
760
+ destroy() { destroyChart(this); }
630
761
  }
631
762
 
632
763
  export function createChart(spec) { return new Chart(spec); }
633
- export function getCapabilities() { return { ...discoverCapabilities(), interactionDefaults: { zoom: false, pan: false, brush: false, drag: false, edgeDrag: false, portConnect: false, editing: false }, diagram: { layoutModes: diagramLayoutModes, routingModes: edgeRoutingModes, entities: ['node', 'edge', 'lane', 'group', 'port', 'waypoint'], operations: ['moveNode', 'moveNodes', 'resizeNode', 'alignNodes', 'snapNodes', 'moveNodeToLane', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'removeEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'], curvedEdges: { renderers: ['canvas', 'svg'], type: 'cubic-bezier', tension: { minimum: 0.2, maximum: 0.8, default: 0.4 }, mindmapDefault: true, obstacleFallback: 'orthogonal', controlPointEditing: false }, edgeEditing: { renderers: ['canvas', 'svg'], selection: true, waypointDrag: true, segmentDrag: true, persistentWaypoints: true, enabledByDefault: false, activation: ['editing.enabled', 'interaction.edgeDrag'] }, validation: ['duplicate-ids', 'missing-endpoints', 'missing-ports', 'missing-lanes', 'invalid-waypoints', 'invalid-curve-tension'] }, editing: { modes: ['preview', 'commit', 'undo', 'redo'], operations: ['updateField', 'updateRecord', 'updateTask', 'shiftTask', 'updateProgress', 'addDependency', 'removeDependency', 'updateMilestone', 'moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'removeEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'], confirmationRequiredByDefault: true, commit: 'local-runtime-host-persistence-required' } }; }
764
+ export { ChartValidationError } from './errors.mjs';
765
+ export function getCapabilities() { const capabilities = discoverCapabilities(); return { ...capabilities, interactionDefaults: { ...capabilities.interactionDefaults }, diagram: { layoutModes: diagramLayoutModes, routingModes: edgeRoutingModes, entities: ['node', 'edge', 'lane', 'group', 'port', 'waypoint'], operations: [...diagramOperations], curvedEdges: { renderers: ['canvas', 'svg'], type: 'cubic-bezier', tension: { minimum: 0.2, maximum: 0.8, default: 0.4 }, mindmapDefault: true, obstacleFallback: 'orthogonal', controlPointEditing: false }, edgeEditing: { renderers: ['canvas', 'svg'], selection: true, waypointDrag: true, segmentDrag: true, persistentWaypoints: true, enabledByDefault: false, activation: ['editing.enabled', 'interaction.edgeDrag'] }, validation: ['duplicate-ids', 'missing-endpoints', 'missing-ports', 'missing-lanes', 'invalid-waypoints', 'invalid-curve-tension'] }, editing: { modes: ['preview', 'commit', 'undo', 'redo'], operations: [...capabilities.commands], confirmationRequiredByDefault: true, commit: 'local-runtime-host-persistence-required' } }; }
634
766
  export function recommend(input, options = {}) { const plan = planChart(input, options); return { primary: plan.primary, alternatives: plan.alternatives, reason: plan.reasons.join(' '), reasons: plan.reasons, confidence: plan.confidence, requiredFields: plan.requiredFields, assumptions: plan.assumptions, warnings: plan.warnings, nextActions: plan.nextActions }; }
635
767
  export { normalizeSpec, validateSpec, normalizeData, inspectData, binData, applyTransforms, data, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, getChartCapability, getPreferenceCapabilities, planChart, explainChart };
636
768
  export { diagramLayoutModes, diagramModes, edgeRoutingModes, normalizeDiagramSpec, validateDiagram, layoutDiagram, routeEdge };
@@ -639,4 +771,4 @@ export { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, cr
639
771
 
640
772
  export { contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin };
641
773
  export { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, mountChartSettings, normalizePreferences, validatePreferences };
642
- export const iChart = { version: '2.0.13', createChart, inspectData, normalizeData, binData, applyTransforms, data, getCapabilities, getChartCapability, getPreferenceCapabilities, planChart, recommend, explainChart, contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, createPreferencesStore, defaultPreferences, normalizePreferences, mergePreferences, validatePreferences, applyPreferencesToSpec, mountChartSettings, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries, normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };
774
+ export const iChart = { version: '2.0.15', createChart, ChartValidationError, inspectData, normalizeData, binData, applyTransforms, data, getCapabilities, getChartCapability, getPreferenceCapabilities, planChart, recommend, explainChart, contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, createPreferencesStore, defaultPreferences, normalizePreferences, mergePreferences, validatePreferences, applyPreferencesToSpec, mountChartSettings, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries, normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };