@taylorwong/ichartjs 2.0.7 → 2.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +2 -2
- package/agent-recipes/minimal-specs.json +25 -0
- package/docs/agent/charting-scenario.md +46 -1
- package/docs/agent/coding-agent-integration.md +1 -1
- package/docs/agent/development/roadmap.md +1 -1
- package/docs/agent/frontend-integration.md +1 -1
- package/docs/agent/project-scenario.md +1 -0
- package/docs/agent/quickstart.md +36 -0
- package/docs/agent/runtime-contract.md +9 -2
- package/docs/agent/usage-scenarios.md +1 -1
- package/docs/agent/zh-CN/charting-scenario.md +31 -1
- package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
- package/docs/agent/zh-CN/frontend-integration.md +1 -1
- package/docs/agent/zh-CN/quickstart.md +36 -0
- package/docs/agent/zh-CN/runtime-contract.md +5 -1
- package/docs/agent/zh-CN/usage-scenarios.md +1 -1
- package/docs/manifests/capabilities.json +12 -0
- package/examples/agent-workflow.mjs +2 -1
- package/package.json +1 -1
- package/skills/ichartjs/SKILL.md +19 -10
- package/skills/ichartjs/references/agent-contract.md +5 -2
- package/skills/ichartjs/references/chart-selection.md +2 -0
- package/src/capabilities.mjs +24 -2
- package/src/charts.mjs +85 -23
- package/src/index.mjs +12 -3
- package/src/spec.mjs +101 -10
- package/types/index.d.ts +8 -5
package/src/capabilities.mjs
CHANGED
|
@@ -21,7 +21,7 @@ const definitions = {
|
|
|
21
21
|
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']) },
|
|
22
22
|
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']) },
|
|
23
23
|
funnel: { family: 'stage', intents: ['funnel', 'conversion'], required: ['dimension', 'measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting'], ['axes', 'grid', 'zoom', 'pan']) },
|
|
24
|
-
gauge: { family: 'indicator', intents: ['progress', 'single-value'], required: ['measure'], optional: [
|
|
24
|
+
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']) },
|
|
25
25
|
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']) },
|
|
26
26
|
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']) },
|
|
27
27
|
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']) },
|
|
@@ -47,12 +47,27 @@ const intentMap = {
|
|
|
47
47
|
schedule: 'gantt', variance: 'gantt', timeline: 'timeline', milestone: 'milestone', progress: 'burndown', release: 'burndown', capacity: 'column', risk: 'scatter', aging: 'bar', matrix: 'heatmap', 'correlation-grid': 'heatmap', multidimensional: 'radar', profile: 'radar', workflow: 'flow', responsibility: 'swimlane', architecture: 'architecture', 'business-architecture': 'architecture', 'data-architecture': 'architecture', 'technical-architecture': 'architecture', mindmap: 'mindmap', hierarchy: 'mindmap', brainstorm: 'mindmap', trend: 'line', 'time-series': 'line', 'part-to-whole': 'pie', distribution: 'column', relationship: 'scatter', correlation: 'scatter', funnel: 'funnel', conversion: 'funnel', 'single-value': 'gauge', ranking: 'bar', comparison: 'bar', composition: 'column'
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
+
const intentAliases = {
|
|
51
|
+
trend: ['trend', 'time-series'], time: ['trend', 'time-series'], compare: ['comparison', 'ranking'], rank: ['ranking', 'comparison'],
|
|
52
|
+
distribution: ['distribution', 'comparison'], relationship: ['relationship', 'correlation'], correlation: ['correlation', 'relationship'],
|
|
53
|
+
matrix: ['matrix', 'correlation-grid'], profile: ['multidimensional', 'profile'], architecture: ['architecture', 'business-architecture', 'data-architecture', 'technical-architecture'],
|
|
54
|
+
mind: ['mindmap', 'hierarchy', 'brainstorm']
|
|
55
|
+
};
|
|
56
|
+
|
|
50
57
|
const alternatives = {
|
|
51
58
|
heatmap: ['scatter', 'column'], radar: ['bar', 'line'], gantt: ['timeline', 'milestone'], timeline: ['milestone', 'gantt'], milestone: ['timeline', 'gantt'], burndown: ['line', 'gantt'], column: ['bar', 'line'], scatter: ['bar', 'line'], bar: ['column', 'line'], flow: ['swimlane', 'architecture'], swimlane: ['flow', 'architecture'], architecture: ['flow', 'mindmap'], mindmap: ['architecture', 'flow'], line: ['area', 'column'], pie: ['bar', 'column'], funnel: ['bar', 'column'], gauge: ['column', 'bar']
|
|
52
59
|
};
|
|
53
60
|
|
|
54
61
|
function warning(code, path, message, suggestion) { return { code, path, message, ...(suggestion ? { suggestion } : {}) }; }
|
|
55
62
|
|
|
63
|
+
function suggestIntents(requested) {
|
|
64
|
+
const value = String(requested || '').trim().toLowerCase();
|
|
65
|
+
if (!value) return ['comparison'];
|
|
66
|
+
if (intentAliases[value]) return intentAliases[value];
|
|
67
|
+
const tokens = value.split(/[^a-z0-9-]+/).filter(Boolean);
|
|
68
|
+
return [...new Set(tokens.flatMap(token => intentAliases[token] || (intentMap[token] ? [token] : [])))].slice(0, 5);
|
|
69
|
+
}
|
|
70
|
+
|
|
56
71
|
export function getChartCapability(type) {
|
|
57
72
|
return chartProfiles[type] ? JSON.parse(JSON.stringify(chartProfiles[type])) : null;
|
|
58
73
|
}
|
|
@@ -104,10 +119,12 @@ export function planChart(input, options = {}) {
|
|
|
104
119
|
const report = inspectData(input);
|
|
105
120
|
const requestedIntent = options.intent || 'comparison';
|
|
106
121
|
const warnings = [...report.warnings];
|
|
122
|
+
const intentKnown = Boolean(intentMap[requestedIntent]);
|
|
123
|
+
const intentSuggestions = intentKnown ? [] : suggestIntents(requestedIntent);
|
|
107
124
|
let primary = intentMap[requestedIntent];
|
|
108
125
|
if (!primary) {
|
|
109
126
|
primary = report.fields.some(field => field.type === 'temporal') ? 'line' : report.measures.length >= 2 && report.dimensions.length === 0 ? 'scatter' : 'bar';
|
|
110
|
-
warnings.push(warning('UNKNOWN_INTENT', 'intent', `Intent ${requestedIntent} is not registered.`, 'Use an intent returned by getCapabilities().intents.'));
|
|
127
|
+
warnings.push(warning('UNKNOWN_INTENT', 'intent', `Intent ${requestedIntent} is not registered.`, intentSuggestions.length ? `Use one of: ${intentSuggestions.join(', ')}.` : 'Use an intent returned by getCapabilities().intents.'));
|
|
111
128
|
}
|
|
112
129
|
const profile = chartProfiles[primary];
|
|
113
130
|
const requiredFields = [];
|
|
@@ -138,6 +155,9 @@ export function planChart(input, options = {}) {
|
|
|
138
155
|
return {
|
|
139
156
|
version: '1.0',
|
|
140
157
|
intent: requestedIntent,
|
|
158
|
+
intentKnown,
|
|
159
|
+
intentSuggestions,
|
|
160
|
+
fallbackUsed: !intentKnown,
|
|
141
161
|
primary,
|
|
142
162
|
alternatives: alternatives[primary] || ['column', 'line'],
|
|
143
163
|
confidence,
|
|
@@ -170,6 +190,7 @@ export function explainChart(spec, model = {}) {
|
|
|
170
190
|
interactions: Object.keys(spec.interaction || {}).filter(key => spec.interaction[key]),
|
|
171
191
|
assumptions: [...(model.data?.assumptions || []), ...(model.state?.projectAnalytics?.assumptions || [])],
|
|
172
192
|
warnings,
|
|
193
|
+
axes: model.state?.axes || null,
|
|
173
194
|
style: spec.theme && typeof spec.theme === 'object' ? { name: spec.theme.name, preset: spec.theme.preset, mode: spec.theme.mode, resolvedMode: spec.theme.resolvedMode, palette: spec.theme.palette, reasons: spec.theme.reasons || [], warnings: spec.theme.warnings || [] } : planStyle(spec),
|
|
174
195
|
lineage: { recordIds: (model.data?.rows || []).map((row, index) => String(row.id ?? row.key ?? `record-${index}`)), sourcePreserved: true },
|
|
175
196
|
accessibility: { enabled: Boolean(spec.accessibility?.enabled), summary: spec.accessibility?.description || spec.title?.text || `${spec.type} chart with ${model.data?.rows?.length || 0} data items.` }
|
|
@@ -208,6 +229,7 @@ export function getCapabilities() {
|
|
|
208
229
|
chartTypes,
|
|
209
230
|
charts: JSON.parse(JSON.stringify(chartProfiles)),
|
|
210
231
|
intents,
|
|
232
|
+
locale: { default: 'en-US', recommended: ['en-US', 'zh-CN'], appliesTo: ['axis', 'labels', 'tooltip', 'export'], inputDates: 'ISO-8601 strings; natural-language date parsing is not supported.' },
|
|
211
233
|
chartModes: { stack: ['stacked', 'percent'], pie: ['standard', 'donut'], composition: ['multi-series', 'mixed-line-column', 'dual-axis'], transforms: ['bin'], diagrams: ['process', 'architecture', 'mindmap'], mindmapLayouts: ['tree', 'radial'], mindmapEdges: ['curved', 'straight', 'orthogonal'] },
|
|
212
234
|
projectManagement: project,
|
|
213
235
|
projectIntelligence: {
|
package/src/charts.mjs
CHANGED
|
@@ -12,6 +12,58 @@ import { axisLabelLayout, boxesOverlap, estimateTextWidth, fontSize, titleLayout
|
|
|
12
12
|
|
|
13
13
|
const pick = (row, encoding, fallback) => row?.[encoding?.field || fallback];
|
|
14
14
|
const font = (spec, role, fallback = '12px system-ui') => spec.theme?.typography?.[role]?.font || fallback;
|
|
15
|
+
const niceMantissas = [1, 1.2, 1.5, 1.8, 2, 2.5, 3, 4, 5, 6, 7.5, 8, 10];
|
|
16
|
+
|
|
17
|
+
function nearestNiceMantissa(value) {
|
|
18
|
+
return niceMantissas.reduce((best, candidate) => Math.abs(Math.log(value / candidate)) < Math.abs(Math.log(value / best)) ? candidate : best, niceMantissas[0]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function niceCeil(value) {
|
|
22
|
+
const positive = Math.abs(Number(value));
|
|
23
|
+
if (!(positive > 0) || !Number.isFinite(positive)) return 1;
|
|
24
|
+
const power = 10 ** Math.floor(Math.log10(positive));
|
|
25
|
+
const normalized = positive / power;
|
|
26
|
+
const mantissa = niceMantissas.find(candidate => candidate >= normalized) || 10;
|
|
27
|
+
return mantissa * power;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validDomain(domain) {
|
|
31
|
+
return Array.isArray(domain) && domain.length === 2 && domain.every(value => Number.isFinite(Number(value))) && Number(domain[1]) > Number(domain[0]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveAxisDomain(rawMin, rawMax, axis = {}, type = 'linear') {
|
|
35
|
+
if (validDomain(axis.domain)) return [Number(axis.domain[0]), Number(axis.domain[1])];
|
|
36
|
+
if (type === 'log') return [rawMin, rawMax];
|
|
37
|
+
let min = rawMin, max = rawMax;
|
|
38
|
+
if (axis.nice !== false) {
|
|
39
|
+
min = min < 0 ? -niceCeil(-min) : 0;
|
|
40
|
+
max = niceCeil(max);
|
|
41
|
+
}
|
|
42
|
+
if (!(max > min)) max = min + (niceCeil(Math.abs(min) || 1) || 1);
|
|
43
|
+
return [min, max];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function axisTicks(min, max, axis = {}, type = 'linear') {
|
|
47
|
+
if (!(max > min)) return [min, max];
|
|
48
|
+
if (type === 'log') {
|
|
49
|
+
const start = Math.ceil(Math.log10(Math.max(min, 0.000001)));
|
|
50
|
+
const end = Math.floor(Math.log10(Math.max(max, 0.000001)));
|
|
51
|
+
const powers = Array.from({ length: Math.max(0, end - start + 1) }, (_, index) => 10 ** (start + index));
|
|
52
|
+
return [...new Set([min, ...powers.filter(value => value > min && value < max), max])];
|
|
53
|
+
}
|
|
54
|
+
const explicitCount = Number.isInteger(axis.ticks) && axis.ticks >= 2 ? axis.ticks : null;
|
|
55
|
+
const targetIntervals = explicitCount ? explicitCount - 1 : 5;
|
|
56
|
+
let intervals = explicitCount ? targetIntervals : 4, bestScore = Infinity;
|
|
57
|
+
if (!explicitCount) {
|
|
58
|
+
for (let candidate = 4; candidate <= 7; candidate += 1) {
|
|
59
|
+
const step = (max - min) / candidate;
|
|
60
|
+
const power = 10 ** Math.floor(Math.log10(Math.abs(step)));
|
|
61
|
+
const score = Math.abs(Math.log((step / power) / nearestNiceMantissa(step / power))) + Math.abs(candidate - targetIntervals) * 0.08;
|
|
62
|
+
if (score < bestScore) { bestScore = score; intervals = candidate; }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return Array.from({ length: intervals + 1 }, (_, index) => min + (max - min) * index / intervals);
|
|
66
|
+
}
|
|
15
67
|
|
|
16
68
|
function chromeLayout(spec, entries = []) {
|
|
17
69
|
const title = titleLayout(spec);
|
|
@@ -62,7 +114,7 @@ function layout(spec, data, legendEntries = []) {
|
|
|
62
114
|
const plotLeft = ['bar', 'heatmap'].includes(spec.type) ? Math.min(width * 0.35, Math.max(p.left, sideLabelLength * fontSize(spec, 'axis', 12) * 0.62 + 24)) : p.left;
|
|
63
115
|
const plotTop = Math.max(p.top, chrome.bottom + (chrome.bottom ? 12 : 0));
|
|
64
116
|
const plotWidth = Math.max(1, width - plotLeft - p.right);
|
|
65
|
-
const axisLabelTexts = temporal ? timeTicks(xMin, xMax, 5).map(value => formatTime(value, xMax - xMin)) : quantitativeX ? timeTicks(xMin, xMax, 5).map(value => formatValue(value, spec.xAxis?.format)) : categories;
|
|
117
|
+
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;
|
|
66
118
|
const xLabels = axisLabelLayout(spec, spec.type === 'bar' ? [] : axisLabelTexts, plotWidth);
|
|
67
119
|
const bottomReserve = Math.max(p.bottom, Math.ceil(16 + xLabels.projectedHeight + (spec.xAxis?.title ? 20 : 0)));
|
|
68
120
|
const plot = { x: plotLeft, y: plotTop, width: plotWidth, height: Math.max(1, height - plotTop - bottomReserve) };
|
|
@@ -75,19 +127,23 @@ function layout(spec, data, legendEntries = []) {
|
|
|
75
127
|
return [values.filter(value => value >= 0).reduce((sum, value) => sum + value, 0), values.filter(value => value < 0).reduce((sum, value) => sum + value, 0)];
|
|
76
128
|
}) : [];
|
|
77
129
|
const numbers = (stackTotals.length ? stackTotals : data.rows.flatMap(row => yEncodings.map(encoding => Number(row[encoding.field])))).filter(Number.isFinite);
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
const x = category => temporal ? plot.x + ((new Date(category).getTime() - xMin) / (xMax - xMin || 1)) * plot.width : quantitativeX ? plot.x + ((Number(category) - xMin) / (xMax - xMin || 1)) * plot.width : plot.x + (categories.indexOf(String(category)) + 0.5) * plot.width / Math.max(1, categories.length);
|
|
130
|
+
const rawMin = Math.min(0, ...(numbers.length ? numbers : [0]));
|
|
131
|
+
const rawMax = Math.max(1, ...(numbers.length ? numbers : [1]));
|
|
81
132
|
const axisType = spec.yAxis?.type || yEncodings[0]?.type || 'linear';
|
|
133
|
+
const [min, max] = resolveAxisDomain(rawMin, rawMax, spec.yAxis || {}, axisType);
|
|
134
|
+
const x = category => temporal ? plot.x + ((new Date(category).getTime() - xMin) / (xMax - xMin || 1)) * plot.width : quantitativeX ? plot.x + ((Number(category) - xMin) / (xMax - xMin || 1)) * plot.width : plot.x + (categories.indexOf(String(category)) + 0.5) * plot.width / Math.max(1, categories.length);
|
|
82
135
|
const transform = value => axisType === 'log' ? Math.log10(Math.max(0.000001, Number(value))) : Number(value);
|
|
83
136
|
const transformedMin = transform(min), transformedMax = transform(max);
|
|
84
137
|
const y = value => plot.y + plot.height - ((transform(value) - transformedMin) / (transformedMax - transformedMin || 1)) * plot.height;
|
|
85
138
|
const yRightNumbers = data.rows.map(row => Number(row[yEncodings[1]?.field])).filter(Number.isFinite);
|
|
86
|
-
const
|
|
139
|
+
const rawRightMin = Math.min(0, ...(yRightNumbers.length ? yRightNumbers : [0])), rawRightMax = Math.max(1, ...(yRightNumbers.length ? yRightNumbers : [1]));
|
|
87
140
|
const rightType = spec.yAxis?.right?.type || yEncodings[1]?.type || 'linear';
|
|
141
|
+
const [rightMin, rightMax] = resolveAxisDomain(rawRightMin, rawRightMax, spec.yAxis?.right || {}, rightType);
|
|
88
142
|
const rightTransform = value => rightType === 'log' ? Math.log10(Math.max(0.000001, Number(value))) : Number(value);
|
|
89
143
|
const yRight = value => plot.y + plot.height - ((rightTransform(value) - rightTransform(rightMin)) / (rightTransform(rightMax) - rightTransform(rightMin) || 1)) * plot.height;
|
|
90
|
-
|
|
144
|
+
const yTicks = axisTicks(min, max, spec.yAxis || {}, axisType), rightTicks = axisTicks(rightMin, rightMax, spec.yAxis?.right || {}, rightType);
|
|
145
|
+
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' });
|
|
146
|
+
return { plot, chrome, xLabels, 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 };
|
|
91
147
|
}
|
|
92
148
|
|
|
93
149
|
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 })); }
|
|
@@ -97,21 +153,21 @@ function addAxes(scene, spec, state) {
|
|
|
97
153
|
scene.add(new SceneNode({ id: 'axis-x', type: 'line', geometry: { x1: plot.x, y1: plot.y + plot.height, x2: plot.x + plot.width, y2: plot.y + plot.height }, style: { stroke: spec.theme.axis } }));
|
|
98
154
|
scene.add(new SceneNode({ id: 'axis-y', type: 'line', geometry: { x1: plot.x, y1: plot.y, x2: plot.x, y2: plot.y + plot.height }, style: { stroke: spec.theme.axis } }));
|
|
99
155
|
if (spec.type === 'bar') {
|
|
100
|
-
const ticks =
|
|
156
|
+
const ticks = state.yTicks, numericX = value => plot.x + ((value - min) / (max - min || 1)) * plot.width;
|
|
101
157
|
if (spec.grid?.visible !== false) ticks.forEach((value, index) => scene.add(new SceneNode({ id: `grid-x-${index}`, type: 'line', geometry: { x1: numericX(value), y1: plot.y, x2: numericX(value), y2: plot.y + plot.height }, style: { stroke: spec.grid?.color || spec.theme.grid, strokeWidth: spec.theme.marks.gridWidth }, zIndex: -2 })));
|
|
102
|
-
ticks.forEach(value => addText(scene, `label-x-value-${value}`, formatValue(value, spec.xAxis?.format || spec.yAxis?.format), numericX(value), plot.y + plot.height + 22, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'middle' }));
|
|
158
|
+
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' }));
|
|
103
159
|
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' }));
|
|
104
160
|
return;
|
|
105
161
|
}
|
|
106
162
|
const labels = state.temporal || state.quantitativeX ? timeTicks(state.xMin, state.xMax, 5) : categories;
|
|
107
|
-
const yTicks =
|
|
163
|
+
const yTicks = state.yTicks;
|
|
108
164
|
if (spec.grid?.visible !== false) yTicks.forEach((value, index) => scene.add(new SceneNode({ id: `grid-y-${index}`, type: 'line', geometry: { x1: plot.x, y1: y(value), x2: plot.x + plot.width, y2: y(value) }, style: { stroke: spec.grid?.color || spec.theme.grid, strokeWidth: spec.theme.marks.gridWidth }, zIndex: -2 })));
|
|
109
165
|
labels.forEach((category, index) => {
|
|
110
166
|
if (index % state.xLabels.step !== 0 && index !== labels.length - 1) return;
|
|
111
|
-
addText(scene, `label-x-${category}`, state.temporal ? formatTime(category, state.xMax - state.xMin) : state.quantitativeX ? formatValue(category, spec.xAxis?.format) : category, x(category), plot.y + plot.height + (state.xLabels.rotation ? 10 : 22), { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: state.xLabels.rotation ? 'end' : 'middle', textBaseline: state.xLabels.rotation ? 'middle' : 'alphabetic', rotation: state.xLabels.rotation });
|
|
167
|
+
addText(scene, `label-x-${category}`, state.temporal ? formatTime(category, state.xMax - state.xMin, spec.xAxis?.format, spec.locale) : state.quantitativeX ? formatValue(category, spec.xAxis?.format, spec.locale) : category, x(category), plot.y + plot.height + (state.xLabels.rotation ? 10 : 22), { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: state.xLabels.rotation ? 'end' : 'middle', textBaseline: state.xLabels.rotation ? 'middle' : 'alphabetic', rotation: state.xLabels.rotation });
|
|
112
168
|
});
|
|
113
|
-
yTicks.forEach(value => addText(scene, `label-y-${value}`, formatValue(value, spec.yAxis?.format), plot.x - 10, y(value) + 4, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'end' }));
|
|
114
|
-
if (state.yEncodings.length > 1 && !spec.stack)
|
|
169
|
+
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' }));
|
|
170
|
+
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') }));
|
|
115
171
|
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' });
|
|
116
172
|
if (spec.yAxis?.title) addText(scene, 'axis-y-title', spec.yAxis.title, 8, plot.y + 12, { fill: spec.theme.text, font: font(spec, 'axis') });
|
|
117
173
|
}
|
|
@@ -134,9 +190,9 @@ function legendEntries(spec, data, series, colors) {
|
|
|
134
190
|
return [];
|
|
135
191
|
}
|
|
136
192
|
|
|
137
|
-
function addMarkLabel(scene, spec, id, value, x, y, dataRef, bounds = null, anchor = null) {
|
|
193
|
+
function addMarkLabel(scene, spec, id, value, x, y, dataRef, bounds = null, anchor = null, formatOverride = null) {
|
|
138
194
|
if (!spec.labels?.enabled) return;
|
|
139
|
-
const text = formatValue(value, spec.labels.format), size = fontSize(spec, 'label', 12), width = estimateTextWidth(text, size), height = size * 1.25;
|
|
195
|
+
const text = formatValue(value, formatOverride || spec.labels.format, spec.locale), size = fontSize(spec, 'label', 12), width = estimateTextWidth(text, size), height = size * 1.25;
|
|
140
196
|
const area = bounds || scene._labelArea || { x: 0, y: 0, width: spec.width, height: spec.height };
|
|
141
197
|
const safeX = Math.max(area.x + width / 2, Math.min(area.x + area.width - width / 2, x));
|
|
142
198
|
const safeY = Math.max(area.y + height / 2, Math.min(area.y + area.height - height / 2, y));
|
|
@@ -166,7 +222,10 @@ function addHeatmapScene(scene, spec, data, state, colors) {
|
|
|
166
222
|
xValues.forEach((value, index) => { if (index % state.xLabels.step !== 0 && index !== xValues.length - 1) return; addText(scene, `heatmap-x-${index}`, value, state.plot.x + (index + .5) * width, state.plot.y + state.plot.height + (state.xLabels.rotation ? 10 : 20), { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: state.xLabels.rotation ? 'end' : 'middle', textBaseline: state.xLabels.rotation ? 'middle' : 'alphabetic', rotation: state.xLabels.rotation }); });
|
|
167
223
|
const yStep = Math.max(1, Math.ceil(fontSize(spec, 'axis', 12) * 1.35 / Math.max(1, height)));
|
|
168
224
|
yValues.forEach((value, index) => { if (index % yStep !== 0 && index !== yValues.length - 1) return; addText(scene, `heatmap-y-${index}`, value, state.plot.x - 10, state.plot.y + (index + .5) * height, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'end', textBaseline: 'middle', baseline: 'middle' }); });
|
|
169
|
-
|
|
225
|
+
let hiddenLabels = 0, visibleLabels = 0;
|
|
226
|
+
data.rows.forEach((row, index) => { const xIndex = xValues.indexOf(String(row[xField])), yIndex = yValues.indexOf(String(row[yField])), value = numericValue(row), ratio = Number.isFinite(value) ? (value - min) / (max - min || 1) : 0, geometry = { x: state.plot.x + xIndex * width + gap / 2, y: state.plot.y + yIndex * height + gap / 2, width: Math.max(1, width - gap), height: Math.max(1, height - gap) }; scene.add(new SceneNode({ id: `heatmap-cell-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: Number.isFinite(value) ? colorMix(low, high, ratio) : missing, stroke: spec.theme.background, strokeWidth: 1 }, dataRef: { dataIndex: index, x: row[xField], y: row[yField], value: Number.isFinite(value) ? value : null, recordId: row.id || `record-${index}` }, interactive: true })); if (spec.labels?.enabled && Number.isFinite(value)) { const label = formatValue(value, spec.labels.format, spec.locale), size = fontSize(spec, 'label', 12); if (geometry.width >= estimateTextWidth(label, size) + 8 && geometry.height >= size * 1.35) { addText(scene, `heatmap-cell-label-${index}`, label, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2, { fill: spec.labels.color || (ratio > 0.58 ? '#ffffff' : spec.theme.text), font: spec.labels.font || font(spec, 'label'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }); visibleLabels += 1; } else hiddenLabels += 1; } });
|
|
227
|
+
state.labelLayout = { ...(state.labelLayout || {}), heatmap: { hidden: hiddenLabels, visible: visibleLabels } };
|
|
228
|
+
if (hiddenLabels) data.warnings.push({ code: 'LABELS_SUPPRESSED', path: 'labels', count: hiddenLabels, message: `${hiddenLabels} heatmap labels were suppressed because cells are too small.`, suggestion: 'Increase chart width or height, reduce the matrix, or disable labels.' });
|
|
170
229
|
state.matrix = { xValues, yValues, min, max };
|
|
171
230
|
}
|
|
172
231
|
|
|
@@ -206,16 +265,16 @@ function addRadarScene(scene, spec, data, state, colors) {
|
|
|
206
265
|
}
|
|
207
266
|
|
|
208
267
|
function timeTicks(min, max, count) { return Array.from({ length: count }, (_, index) => min + (max - min) * index / Math.max(1, count - 1)); }
|
|
209
|
-
function formatTime(value, span) { const date = new Date(value); if (
|
|
268
|
+
function formatTime(value, span, format, locale = 'en-US') { const date = new Date(value); if (format) { const options = typeof format === 'string' ? { style: format } : format; return formatValue(value, options?.style || options?.options ? { style: options.style || 'date', ...options } : { style: 'date', options }, locale); } const days = span / 86400000, nowYear = date.getUTCFullYear(), startYear = new Date(value - span).getUTCFullYear(); if (days > 365 || (nowYear !== startYear && days > 45)) return String(nowYear); if (days > 60) return `${nowYear}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`; return `${date.getUTCMonth() + 1}/${date.getUTCDate()}${nowYear !== startYear ? `/${nowYear}` : ''}`; }
|
|
210
269
|
|
|
211
270
|
function projectRows(spec, data) { return data.rows.length ? data.rows : (spec.tasks || spec.events || []); }
|
|
212
271
|
function projectDate(value, min, max, plot) { const time = new Date(value).getTime(); return plot.x + ((time - min) / (max - min || 1)) * plot.width; }
|
|
213
|
-
function addProjectAxes(scene, plot, min, max) { scene.add(new SceneNode({ id: 'project-axis', type: 'line', geometry: { x1: plot.x, y1: plot.y + plot.height, x2: plot.x + plot.width, y2: plot.y + plot.height }, style: { stroke: '#94a3b8' } })); timeTicks(min, max, 5).forEach(value => addText(scene, `project-label-${value}`, formatTime(value, max - min), projectDate(value, min, max, plot), plot.y + plot.height + 22, { fill: '#475569', font: '12px system-ui', textAnchor: 'middle' })); }
|
|
272
|
+
function addProjectAxes(scene, plot, min, max, spec = {}) { scene.add(new SceneNode({ id: 'project-axis', type: 'line', geometry: { x1: plot.x, y1: plot.y + plot.height, x2: plot.x + plot.width, y2: plot.y + plot.height }, style: { stroke: '#94a3b8' } })); timeTicks(min, max, 5).forEach(value => addText(scene, `project-label-${value}`, formatTime(value, max - min, spec.xAxis?.format, spec.locale), projectDate(value, min, max, plot), plot.y + plot.height + 22, { fill: '#475569', font: '12px system-ui', textAnchor: 'middle' })); }
|
|
214
273
|
function addProjectScene(scene, spec, data, state, colors) {
|
|
215
274
|
const { plot } = state;
|
|
216
275
|
if (['gantt', 'timeline', 'milestone'].includes(spec.type)) {
|
|
217
276
|
const rows = projectRows(spec, data), dates = rows.flatMap(row => [new Date(row.start || row.date || row.end).getTime(), new Date(row.end || row.date || row.start).getTime()]).filter(Number.isFinite), min = Math.min(...(dates.length ? dates : [Date.now()])), max = Math.max(...(dates.length ? dates : [min + 86400000]));
|
|
218
|
-
addProjectAxes(scene, plot, min, max);
|
|
277
|
+
addProjectAxes(scene, plot, min, max, spec);
|
|
219
278
|
const positions = new Map(); rows.forEach((row, index) => { const y = plot.y + (index + 0.5) * plot.height / Math.max(1, rows.length), start = projectDate(row.start || row.date || row.end, min, max, plot), end = projectDate(row.end || row.date || row.start, min, max, plot), x = Math.min(start, end), width = Math.max(8, Math.abs(end - start)); positions.set(row.id, { x, y, end, row }); const milestone = spec.type === 'milestone' || row.milestone || start === end; const geometry = milestone ? { cx: start, cy: y, r: 7 } : { x, y: y - 10, width, height: 20 }; scene.add(new SceneNode({ id: `project-item-${index}`, type: milestone ? 'circle' : 'rect', geometry, bounds: milestone ? { x: start - 10, y: y - 10, width: 20, height: 20 } : geometry, style: { fill: colors[index % colors.length], stroke: '#ffffff', strokeWidth: 1 }, dataRef: { dataIndex: index, taskId: row.id, field: 'project' }, interactive: true })); addText(scene, `project-label-${index}`, row.name || row.title || row.label || row.id || `Item ${index + 1}`, plot.x - 8, y + 4, { fill: '#334155', font: '12px system-ui', textAnchor: 'end' }); if (row.progress != null && !milestone) scene.add(new SceneNode({ id: `project-progress-${index}`, type: 'rect', geometry: { x, y: y - 10, width: width * Math.max(0, Math.min(1, Number(row.progress) > 1 ? Number(row.progress) / 100 : Number(row.progress))), height: 20 }, style: { fill: '#0f172a', opacity: 0.25 }, interactive: false })); });
|
|
220
279
|
const criticalPath = new Set(spec.criticalPath || []);
|
|
221
280
|
rows.forEach((row, index) => (row.dependencies || []).forEach((dependency, dependencyIndex) => { const from = positions.get(dependency), to = positions.get(row.id); if (!from || !to) return; const critical = criticalPath.has(row.id) && criticalPath.has(dependency); const angle = Math.atan2(to.y - from.y, to.x - from.end), arrow = 7; scene.add(new SceneNode({ id: `dependency-${index}-${dependencyIndex}`, type: 'line', geometry: { x1: from.end, y1: from.y, x2: to.x, y2: to.y }, style: { stroke: critical ? '#dc2626' : '#64748b', strokeWidth: critical ? 2.5 : 1.5, opacity: 0.85 }, dataRef: { from: dependency, to: row.id, critical }, interactive: false, zIndex: -1 })); scene.add(new SceneNode({ id: `dependency-arrow-${index}-${dependencyIndex}`, type: 'path', geometry: { points: [{ x: to.x, y: to.y }, { x: to.x - arrow * Math.cos(angle - Math.PI / 6), y: to.y - arrow * Math.sin(angle - Math.PI / 6) }, { x: to.x - arrow * Math.cos(angle + Math.PI / 6), y: to.y - arrow * Math.sin(angle + Math.PI / 6) }] }, style: { stroke: critical ? '#dc2626' : '#64748b', fill: critical ? '#dc2626' : '#64748b', strokeWidth: 1 }, interactive: false, zIndex: -1 })); }));
|
|
@@ -301,15 +360,18 @@ export function buildScene(spec) {
|
|
|
301
360
|
} else if (spec.type === 'scatter') {
|
|
302
361
|
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 }); });
|
|
303
362
|
} else if (spec.type === 'pie') {
|
|
304
|
-
const categoryField = spec.encoding.category?.field || 'name', valueField = spec.encoding.value?.field || 'value',
|
|
305
|
-
if (
|
|
306
|
-
|
|
363
|
+
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;
|
|
364
|
+
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.' });
|
|
365
|
+
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' }); }
|
|
366
|
+
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, 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: colors[index % colors.length], stroke: spec.theme.background, strokeWidth: 1 }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value / total, cx + Math.cos(middle) * radius * .72, cy + Math.sin(middle) * radius * .72, dataRef, state.plot, null, spec.labels?.format || { style: 'percent' }); angle = end; });
|
|
307
367
|
} else if (spec.type === 'funnel') {
|
|
308
368
|
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;
|
|
309
369
|
data.rows.forEach((row, index) => { const value = Number(row[valueField]) || 0, ratio = Math.max(0.1, value / maxValue); const width = state.plot.width * ratio; const geometry = { x: state.plot.x + (state.plot.width - width) / 2, y: state.plot.y + index * segmentHeight, width, height: Math.max(2, segmentHeight - 3) }, dataRef = { seriesIndex: 0, dataIndex: index, field: valueField }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: colors[index % colors.length] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value, spec.width / 2, geometry.y + geometry.height / 2 + 4, dataRef, state.plot); });
|
|
310
370
|
} else if (spec.type === 'gauge') {
|
|
311
|
-
const valueField = spec.encoding.value?.field || 'value', domain = spec.domain
|
|
312
|
-
|
|
371
|
+
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;
|
|
372
|
+
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.' });
|
|
373
|
+
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.' });
|
|
374
|
+
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' });
|
|
313
375
|
}
|
|
314
376
|
addLegend(scene, spec, state.chrome.legend);
|
|
315
377
|
addBranding(scene, spec);
|
package/src/index.mjs
CHANGED
|
@@ -344,13 +344,22 @@ export class Chart {
|
|
|
344
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; }
|
|
345
345
|
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
346
|
getSpec() { return JSON.parse(JSON.stringify(this.spec)); }
|
|
347
|
-
|
|
347
|
+
_getDiagnostics() { return [...new Map([...(this._specDiagnostics?.warnings || []), ...(this.model.data?.warnings || []), ...(this.spec.theme?.warnings || [])].map(item => [`${item.code}:${item.path || ''}`, item])).values()]; }
|
|
348
|
+
_getHealth(warnings) {
|
|
349
|
+
const isEmpty = this.model.data.rows.length === 0 && !['flow', 'swimlane', 'architecture', 'mindmap'].includes(this.spec.type) || warnings.some(item => item.code === 'ZERO_TOTAL');
|
|
350
|
+
const marks = [];
|
|
351
|
+
this.model.scene?.walk?.(node => { if (node.id !== 'root' && !['text', 'line'].includes(node.type)) marks.push(node); });
|
|
352
|
+
const suppressedLabels = warnings.filter(item => item.code === 'LABELS_SUPPRESSED').reduce((sum, item) => sum + Number(item.count || 0), 0);
|
|
353
|
+
const clampedValues = warnings.filter(item => item.code === 'VALUE_CLAMPED').reduce((sum, item) => sum + Number(item.count || 1), 0);
|
|
354
|
+
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
|
+
}
|
|
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) }; }
|
|
348
357
|
getProjectAnalytics() { return clone(this.model.state?.projectAnalytics || null); }
|
|
349
358
|
getLinkedState() { return clone(this.model.state?.linked || null); }
|
|
350
359
|
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(); }
|
|
351
360
|
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(); }
|
|
352
361
|
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]) }; }
|
|
353
|
-
explain() {
|
|
362
|
+
explain() { const explanation = explainChart(this.spec, this.model), state = this.getState(); return { ...explanation, warnings: state.warnings, health: state.health }; }
|
|
354
363
|
getAccessibleDescription() { const description = this.spec.accessibility?.description || this.spec.title?.text || `${this.spec.type} chart`; return `${description}; ${this.model.data.rows.length} data items.`; }
|
|
355
364
|
inspectDataSchema() { return inspectDataSchema(this.spec.data.schema || this.spec.schema); }
|
|
356
365
|
validateData() { return validateData(this.toDataTable(), this.spec.data.schema || this.spec.schema, this.spec.validationOptions); }
|
|
@@ -630,4 +639,4 @@ export { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, cr
|
|
|
630
639
|
|
|
631
640
|
export { contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin };
|
|
632
641
|
export { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, mountChartSettings, normalizePreferences, validatePreferences };
|
|
633
|
-
export const iChart = { version: '2.0.
|
|
642
|
+
export const iChart = { version: '2.0.9', 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 };
|
package/src/spec.mjs
CHANGED
|
@@ -23,7 +23,8 @@ const defaults = {
|
|
|
23
23
|
labels: { enabled: false },
|
|
24
24
|
branding: { enabled: true },
|
|
25
25
|
interaction: { tooltip: true, hover: true, click: true, crosshair: false, zoom: false, pan: false, brush: false, drag: false, edgeDrag: false, portConnect: false, keyboard: true }
|
|
26
|
-
,responsive: [], accessibility: { enabled: false }, editing: { enabled: false, mode: 'command', requireConfirmation: true, allowDelete: false, allowStructuralChanges: false }, xAxis: {}, yAxis: {}
|
|
26
|
+
,responsive: [], accessibility: { enabled: false }, editing: { enabled: false, mode: 'command', requireConfirmation: true, allowDelete: false, allowStructuralChanges: false }, xAxis: {}, yAxis: { nice: true, ticks: 'auto' }
|
|
27
|
+
,locale: 'en-US'
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
function clone(value) {
|
|
@@ -59,6 +60,44 @@ function dependencyErrors(rows) {
|
|
|
59
60
|
return errors;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
const encodingChannels = {
|
|
64
|
+
line: ['x', 'y'], area: ['x', 'y'], bar: ['x', 'y'], column: ['x', 'y'], scatter: ['x', 'y'],
|
|
65
|
+
pie: ['category', 'value'], funnel: ['category', 'value'], gauge: ['value'], heatmap: ['x', 'y', 'color'], radar: [],
|
|
66
|
+
gantt: [], timeline: [], milestone: [], burndown: [], flow: [], swimlane: [], architecture: [], mindmap: []
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const presentationEncodingKeys = new Set(['title', 'format', 'labels', 'legend']);
|
|
70
|
+
const diagramTypes = new Set(['flow', 'swimlane', 'architecture', 'mindmap']);
|
|
71
|
+
const diagramFields = ['nodes', 'edges', 'lanes', 'groups', 'layers', 'boundaries'];
|
|
72
|
+
const nonCartesianAnalysisTypes = new Set(['pie', 'funnel', 'gauge', 'heatmap', 'radar']);
|
|
73
|
+
|
|
74
|
+
function finiteDomain(domain) {
|
|
75
|
+
return Array.isArray(domain) && domain.length === 2 && domain.every(value => Number.isFinite(Number(value))) && Number(domain[1]) > Number(domain[0]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateEncodingContract(input, spec, errors) {
|
|
79
|
+
const rows = Array.isArray(spec.data?.values) ? spec.data.values : [], fields = new Set(rows.flatMap(row => Object.keys(row || {})));
|
|
80
|
+
if (!rows.length) return;
|
|
81
|
+
const rawEncoding = input.encoding && typeof input.encoding === 'object' ? input.encoding : {};
|
|
82
|
+
const allowed = new Set(encodingChannels[spec.type] || []);
|
|
83
|
+
Object.keys(rawEncoding).forEach(channel => {
|
|
84
|
+
if (presentationEncodingKeys.has(channel) || allowed.has(channel)) return;
|
|
85
|
+
errors.push({ code: 'UNSUPPORTED_ENCODING_CHANNEL', path: `encoding.${channel}`, message: `${spec.type} does not use encoding.${channel}.`, expected: [...allowed], suggestion: allowed.size ? `Use encoding.${[...allowed].join(' or encoding.')} for ${spec.type}.` : 'Remove field encodings from this chart type.' });
|
|
86
|
+
});
|
|
87
|
+
const check = (path, encoding) => {
|
|
88
|
+
const items = Array.isArray(encoding) ? encoding : [encoding];
|
|
89
|
+
items.forEach((item, index) => {
|
|
90
|
+
if (!item) return;
|
|
91
|
+
const field = item.field, itemPath = Array.isArray(encoding) ? `${path}.${index}.field` : `${path}.field`;
|
|
92
|
+
if (typeof field !== 'string' || !field) errors.push({ code: 'INVALID_ENCODING_FIELD', path: itemPath, message: 'Encoding fields must be non-empty strings.', suggestion: 'Use a field name that exists in data.values.' });
|
|
93
|
+
else if (!fields.has(field)) errors.push({ code: 'MISSING_ENCODING_FIELD', path: itemPath, message: `Field ${field} is not present in data.values.`, expected: [...fields], suggestion: 'Inspect the data schema and use an existing field.' });
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
if (['pie', 'funnel'].includes(spec.type) && (rawEncoding.x !== undefined || rawEncoding.y !== undefined)) return;
|
|
97
|
+
(encodingChannels[spec.type] || []).forEach(channel => check(`encoding.${channel}`, spec.encoding?.[channel]));
|
|
98
|
+
if (spec.type === 'radar') (spec.indicators || []).forEach((indicator, index) => check(`indicators.${index}`, indicator));
|
|
99
|
+
}
|
|
100
|
+
|
|
62
101
|
export function normalizeSpec(input = {}) {
|
|
63
102
|
const spec = merge(defaults, input);
|
|
64
103
|
if (input.branding === undefined && input.theme && typeof input.theme === 'object' && input.theme.branding !== undefined) spec.branding = clone(input.theme.branding);
|
|
@@ -69,22 +108,26 @@ export function normalizeSpec(input = {}) {
|
|
|
69
108
|
if (spec.branding.enabled === undefined) spec.branding = { ...spec.branding, enabled: true };
|
|
70
109
|
}
|
|
71
110
|
if (!spec.data) spec.data = { values: [] };
|
|
72
|
-
if (
|
|
111
|
+
if (diagramTypes.has(spec.type) && !Array.isArray(spec.data.values)) spec.data.values = [];
|
|
73
112
|
if (Array.isArray(spec.data)) spec.data = { values: spec.data };
|
|
74
113
|
if (!spec.encoding) spec.encoding = {};
|
|
75
|
-
if (['pie', 'funnel'
|
|
114
|
+
if (['pie', 'funnel'].includes(spec.type) && !spec.encoding.category) spec.encoding.category = { field: 'name', type: 'category' };
|
|
76
115
|
if (['pie', 'funnel', 'gauge'].includes(spec.type) && !spec.encoding.value) spec.encoding.value = { field: 'value', type: 'quantitative' };
|
|
77
116
|
if (spec.type === 'scatter' && !input.encoding?.x) spec.encoding.x = { field: 'x', type: 'quantitative' };
|
|
78
117
|
if (spec.type === 'scatter' && !input.encoding?.y) spec.encoding.y = { field: 'y', type: 'quantitative' };
|
|
79
118
|
if (spec.type === 'heatmap') { spec.encoding.x ||= { field: 'x', type: 'category' }; spec.encoding.y ||= { field: 'y', type: 'category' }; spec.encoding.color ||= { field: 'value', type: 'quantitative' }; }
|
|
80
|
-
if (!['pie', 'funnel', 'gauge', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type)) {
|
|
119
|
+
if (!['pie', 'funnel', 'gauge', 'radar', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type)) {
|
|
81
120
|
spec.encoding.x = spec.encoding.x || { field: 'name', type: 'category' };
|
|
82
121
|
spec.encoding.y = spec.encoding.y || { field: 'value', type: 'quantitative' };
|
|
83
122
|
}
|
|
123
|
+
if (nonCartesianAnalysisTypes.has(spec.type)) {
|
|
124
|
+
['grid', 'xAxis', 'yAxis'].forEach(key => { if (input[key] === undefined) delete spec[key]; });
|
|
125
|
+
['legend', 'labels'].forEach(key => { if (input[key] === undefined && chartProfiles[spec.type]?.features?.[key] !== 'supported') delete spec[key]; });
|
|
126
|
+
}
|
|
84
127
|
return spec;
|
|
85
128
|
}
|
|
86
129
|
|
|
87
|
-
export function validateSpec(input) {
|
|
130
|
+
export function validateSpec(input = {}) {
|
|
88
131
|
const spec = normalizeSpec(input);
|
|
89
132
|
const errors = [], warnings = [], normalizations = [];
|
|
90
133
|
if (!chartTypes.has(spec.type)) errors.push({ code: 'INVALID_TYPE', path: 'type', message: `Unsupported chart type: ${spec.type}`, suggestion: 'Use a type returned by getCapabilities().' });
|
|
@@ -97,15 +140,51 @@ export function validateSpec(input) {
|
|
|
97
140
|
if (spec.theme.branding != null && typeof spec.theme.branding !== 'boolean' && !(spec.theme.branding && typeof spec.theme.branding === 'object')) errors.push({ code: 'INVALID_BRANDING', path: 'theme.branding', message: 'theme.branding must be a boolean or a { enabled: boolean } object.', suggestion: 'Use theme: { branding: false } or theme: { branding: { enabled: false } }.' });
|
|
98
141
|
else if (spec.theme.branding && typeof spec.theme.branding === 'object' && (Object.keys(spec.theme.branding).some(key => key !== 'enabled') || typeof spec.theme.branding.enabled !== 'boolean')) errors.push({ code: 'INVALID_BRANDING', path: 'theme.branding', message: 'theme.branding objects only support a boolean enabled property.', suggestion: 'Use theme: { branding: { enabled: false } }.' });
|
|
99
142
|
}
|
|
143
|
+
if (typeof spec.locale !== 'string' || !spec.locale.trim()) errors.push({ code: 'INVALID_LOCALE', path: 'locale', message: 'locale must be a non-empty BCP 47 locale string.', suggestion: 'Use for example locale: "en-US" or locale: "zh-CN".' });
|
|
144
|
+
validateEncodingContract(input, spec, errors);
|
|
145
|
+
const axisEncodings = [['x', 'xAxis'], ['y', 'yAxis']];
|
|
146
|
+
axisEncodings.forEach(([encodingName, axisName]) => {
|
|
147
|
+
const encoding = spec.encoding?.[encodingName];
|
|
148
|
+
const encodingItems = Array.isArray(encoding) ? encoding : [encoding];
|
|
149
|
+
encodingItems.forEach((item, index) => {
|
|
150
|
+
if (!item || typeof item !== 'object') return;
|
|
151
|
+
const pathPrefix = Array.isArray(encoding) ? `encoding.${encodingName}.${index}` : `encoding.${encodingName}`;
|
|
152
|
+
if (item.title !== undefined) warnings.push({ code: 'MISPLACED_AXIS_TITLE', path: `${pathPrefix}.title`, message: `Axis titles belong on ${axisName}.title, not on an encoding.`, suggestion: `Move it to ${axisName}: { title: ... }.` });
|
|
153
|
+
if (item.format !== undefined) warnings.push({ code: 'MISPLACED_AXIS_FORMAT', path: `${pathPrefix}.format`, message: `Axis formats belong on ${axisName}.format, not on an encoding.`, suggestion: `Move it to ${axisName}: { format: ... }.` });
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
if (spec.encoding?.labels !== undefined) warnings.push({ code: 'MISPLACED_LABELS', path: 'encoding.labels', message: 'Data labels are a chart-level option.', suggestion: 'Move it to labels: { enabled: true, ... }.' });
|
|
157
|
+
if (spec.encoding?.legend !== undefined) warnings.push({ code: 'MISPLACED_LEGEND', path: 'encoding.legend', message: 'Legend visibility is a chart-level option.', suggestion: 'Move it to legend: { visible: false }.' });
|
|
158
|
+
[['xAxis', spec.xAxis], ['yAxis', spec.yAxis]].forEach(([axisName, axis]) => {
|
|
159
|
+
['min', 'max'].forEach(bound => {
|
|
160
|
+
if (axis?.[bound] !== undefined || axis?.right?.[bound] !== undefined) warnings.push({ code: 'UNSUPPORTED_AXIS_DOMAIN', path: `${axisName}.${bound}`, message: 'Use axis.domain rather than axis.min/axis.max for an explicit numeric domain.', suggestion: 'Use yAxis: { domain: [0, 2000] } or yAxis: { nice: true }.' });
|
|
161
|
+
});
|
|
162
|
+
if (axisName === 'xAxis' && axis?.domain !== undefined) warnings.push({ code: 'UNSUPPORTED_AXIS_DOMAIN', path: 'xAxis.domain', message: 'Explicit x-axis domain is not supported by the current categorical/time layout.', suggestion: 'Use yAxis.domain for numeric value axes; x-axis ranges are derived from records.' });
|
|
163
|
+
if (axisName === 'xAxis' && (axis?.nice !== undefined || axis?.ticks !== undefined)) warnings.push({ code: 'UNSUPPORTED_AXIS_TICKS', path: 'xAxis', message: 'Nice domains and tick counts currently apply to yAxis, not categorical/time x-axis layouts.', suggestion: 'Move numeric scale controls to yAxis.' });
|
|
164
|
+
if (axisName === 'xAxis') return;
|
|
165
|
+
[axis, axis?.right].forEach((axisConfig, index) => {
|
|
166
|
+
if (!axisConfig) return;
|
|
167
|
+
const pathPrefix = index ? `${axisName}.right` : axisName;
|
|
168
|
+
if (axisConfig.domain !== undefined && !(Array.isArray(axisConfig.domain) && axisConfig.domain.length === 2 && axisConfig.domain.every(value => Number.isFinite(Number(value))) && Number(axisConfig.domain[1]) > Number(axisConfig.domain[0]))) errors.push({ code: 'INVALID_AXIS_DOMAIN', path: `${pathPrefix}.domain`, message: 'Axis domain must be [min, max] with two finite numbers and max greater than min.', suggestion: 'Use for example yAxis: { domain: [0, 2000] }.' });
|
|
169
|
+
if (axisConfig.nice !== undefined && typeof axisConfig.nice !== 'boolean') errors.push({ code: 'INVALID_AXIS_NICE', path: `${pathPrefix}.nice`, message: 'Axis nice must be a boolean.', suggestion: 'Use nice: true or nice: false.' });
|
|
170
|
+
if (axisConfig.ticks !== undefined && axisConfig.ticks !== 'auto' && !(Number.isInteger(axisConfig.ticks) && axisConfig.ticks >= 2)) errors.push({ code: 'INVALID_AXIS_TICKS', path: `${pathPrefix}.ticks`, message: 'Axis ticks must be auto or an integer of at least 2 labels.', suggestion: 'Use ticks: "auto" or ticks: 5.' });
|
|
171
|
+
});
|
|
172
|
+
});
|
|
100
173
|
if (Array.isArray(spec.encoding.y) && spec.encoding.y.length > 2) errors.push({ code: 'TOO_MANY_AXES', path: 'encoding.y', message: 'Only two quantitative axes are supported in this iteration.', suggestion: 'Use at most two y encodings.' });
|
|
101
174
|
if (spec.stack && !['bar', 'column', 'area'].includes(spec.type)) errors.push({ code: 'INVALID_STACK', path: 'stack', message: 'Stacking is supported by bar, column, and area charts.', suggestion: 'Remove stack or use a supported chart type.' });
|
|
102
175
|
if (spec.stack && !['stacked', 'percent'].includes(typeof spec.stack === 'string' ? spec.stack : spec.stack.mode)) errors.push({ code: 'INVALID_STACK_MODE', path: 'stack', message: 'Stack mode must be stacked or percent.', suggestion: 'Use stack: "stacked" or stack: "percent".' });
|
|
103
176
|
if (spec.type === 'pie' && spec.innerRadius != null && (!(Number(spec.innerRadius) >= 0) || Number(spec.innerRadius) >= 1)) errors.push({ code: 'INVALID_INNER_RADIUS', path: 'innerRadius', message: 'Pie innerRadius must be a ratio from 0 up to, but not including, 1.', suggestion: 'Use a value such as 0.55.' });
|
|
177
|
+
if (spec.type === 'gauge') {
|
|
178
|
+
if (spec.domain === undefined) errors.push({ code: 'MISSING_GAUGE_DOMAIN', path: 'domain', message: 'Gauge requires an explicit numeric domain to avoid silently clipping values.', suggestion: 'Use domain: [0, 100] for a percentage KPI or declare the business range.' });
|
|
179
|
+
else if (!finiteDomain(spec.domain)) errors.push({ code: 'INVALID_GAUGE_DOMAIN', path: 'domain', message: 'Gauge domain must be [min, max] with finite numbers and max greater than min.', suggestion: 'Use for example domain: [0, 100].' });
|
|
180
|
+
}
|
|
104
181
|
if (spec.type === 'radar' && (!Array.isArray(spec.indicators) || spec.indicators.length < 3)) errors.push({ code: 'INVALID_INDICATORS', path: 'indicators', message: 'Radar requires at least three indicators.', suggestion: 'Declare indicator name, field, min, and max.' });
|
|
105
|
-
if (!spec.data || !Array.isArray(spec.data.values)) errors.push({ code: 'INVALID_DATA', path: 'data.values', message: 'data.values must be an array.', suggestion: 'Pass an array of row objects.' });
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
182
|
+
if (!diagramTypes.has(spec.type) && (!spec.data || !Array.isArray(spec.data.values))) errors.push({ code: 'INVALID_DATA', path: 'data.values', message: 'data.values must be an array.', suggestion: 'Pass an array of row objects.' });
|
|
183
|
+
const misplacedDiagramFields = diagramTypes.has(spec.type) ? diagramFields.filter(field => input.data && input.data[field] !== undefined) : [];
|
|
184
|
+
misplacedDiagramFields.forEach(field => errors.push({ code: 'MISPLACED_DIAGRAM_FIELD', path: `data.${field}`, message: `${field} belongs at the top level of a ${spec.type} Spec.`, suggestion: `Move data.${field} to ${field}.` }));
|
|
185
|
+
if (diagramTypes.has(spec.type) && !Array.isArray(spec.nodes) && !misplacedDiagramFields.includes('nodes')) errors.push({ code: 'INVALID_NODES', path: 'nodes', message: `${spec.type} charts require top-level nodes.`, suggestion: 'Pass nodes: [{ id, label }].' });
|
|
186
|
+
if (diagramTypes.has(spec.type)) {
|
|
187
|
+
const diagramInput = { type: spec.type, ...(Array.isArray(spec.nodes) ? { nodes: spec.nodes } : {}), ...(Array.isArray(spec.edges) ? { edges: spec.edges } : {}), ...(Array.isArray(spec.lanes) ? { lanes: spec.lanes } : {}), ...(Array.isArray(spec.groups) ? { groups: spec.groups } : {}), ...(Array.isArray(spec.layers) ? { layers: spec.layers } : {}), ...(Array.isArray(spec.boundaries) ? { boundaries: spec.boundaries } : {}), ...(spec.diagram ? { diagram: spec.diagram } : {}) };
|
|
109
188
|
const diagramValidation = validateDiagram(diagramInput);
|
|
110
189
|
diagramValidation.errors.forEach(error => errors.push({ ...error, path: error.path || 'diagram' }));
|
|
111
190
|
}
|
|
@@ -115,8 +194,20 @@ export function validateSpec(input) {
|
|
|
115
194
|
rows.forEach((row, index) => { const values = spec.type === 'gantt' ? [row.start, row.end] : [row.date]; if (values.some(value => value != null && Number.isNaN(Date.parse(value)))) errors.push({ code: 'INVALID_DATE', path: `data.values[${index}]`, message: `${spec.type} contains an invalid date.`, suggestion: 'Use an ISO-8601 date such as 2026-09-14.' }); });
|
|
116
195
|
}
|
|
117
196
|
if (spec.type === 'gantt') errors.push(...dependencyErrors(spec.data.values));
|
|
118
|
-
|
|
197
|
+
const rows = Array.isArray(spec.data?.values) ? spec.data.values : [];
|
|
198
|
+
if (spec.type === 'pie' && rows.length > 8) warnings.push({ code: 'HIGH_CARDINALITY_PIE', path: 'data.values', message: `Pie contains ${rows.length} categories.`, expected: '8 or fewer categories', suggestion: 'Use bar/column or group smaller categories.' });
|
|
199
|
+
if (spec.type === 'pie') {
|
|
200
|
+
const valueField = spec.encoding.value?.field || 'value', values = rows.map(row => Number(row?.[valueField])), negativeCount = values.filter(value => Number.isFinite(value) && value < 0).length, positiveTotal = values.reduce((sum, value) => sum + (Number.isFinite(value) && value > 0 ? value : 0), 0);
|
|
201
|
+
if (negativeCount) 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.' });
|
|
202
|
+
if (!(positiveTotal > 0)) 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.' });
|
|
203
|
+
}
|
|
119
204
|
if (spec.type === 'radar' && Array.isArray(spec.indicators) && spec.indicators.some(indicator => !Number.isFinite(Number(indicator.min)) || !Number.isFinite(Number(indicator.max)))) warnings.push({ code: 'AMBIGUOUS_RADAR_DOMAIN', path: 'indicators', message: 'Radar indicator domains are incomplete.', expected: 'finite min and max for every indicator', suggestion: 'Declare explicit domains, especially for mixed units.' });
|
|
205
|
+
const profile = chartProfiles[spec.type], inputOptions = input && typeof input === 'object' ? input : {};
|
|
206
|
+
[['legend', 'legend'], ['grid', 'grid'], ['labels', 'labels']].forEach(([option, feature]) => {
|
|
207
|
+
if (inputOptions[option] !== undefined && profile?.features?.[feature] !== 'supported') warnings.push({ code: `UNSUPPORTED_${option.toUpperCase()}`, path: option, message: `${spec.type} does not support ${option} configuration in the current contract.`, suggestion: 'Remove the option or use a chart type that declares this feature.' });
|
|
208
|
+
});
|
|
209
|
+
if ((inputOptions.xAxis !== undefined || inputOptions.yAxis !== undefined) && !['line', 'area', 'bar', 'column', 'scatter'].includes(spec.type)) warnings.push({ code: 'UNSUPPORTED_AXIS', path: inputOptions.xAxis !== undefined ? 'xAxis' : 'yAxis', message: `${spec.type} does not expose Cartesian axis configuration.`, suggestion: 'Use chart-specific options such as domain, colorScale, or indicators.' });
|
|
210
|
+
if (spec.type === 'swimlane' && !(Array.isArray(spec.lanes) && spec.lanes.length)) errors.push({ code: 'MISSING_REQUIRED', path: 'lanes', message: 'Swimlane requires at least one top-level lane.', suggestion: 'Provide lanes: [{ id, label }].' });
|
|
120
211
|
const supportedInteractions = chartProfiles[spec.type]?.interactions || [];
|
|
121
212
|
Object.entries(spec.interaction || {}).forEach(([name, enabled]) => { if (enabled && !supportedInteractions.includes(name) && !['hover', 'click'].includes(name)) warnings.push({ code: 'UNSUPPORTED_INTERACTION', path: `interaction.${name}`, message: `${name} is not declared for ${spec.type}.`, expected: supportedInteractions, suggestion: 'Disable the interaction or use a compatible chart type.' }); });
|
|
122
213
|
if (spec.branding != null && typeof spec.branding !== 'boolean' && !(spec.branding && typeof spec.branding === 'object')) {
|