@taylorwong/ichartjs 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +201 -0
  3. package/README.md +194 -0
  4. package/agent-recipes/diagrams/agent-orchestration.json +18 -0
  5. package/agent-recipes/diagrams/approval-process.json +16 -0
  6. package/agent-recipes/diagrams/responsibility-mapping.json +13 -0
  7. package/agent-recipes/diagrams/workflow.json +19 -0
  8. package/agent-recipes/foundational-analysis.json +18 -0
  9. package/agent-recipes/project-management.json +47 -0
  10. package/agent-recipes/trend-line.json +21 -0
  11. package/docs/agent/README.md +62 -0
  12. package/docs/agent/charting-scenario.md +82 -0
  13. package/docs/agent/coding-agent-integration.md +88 -0
  14. package/docs/agent/development/2.0-release-readiness.md +65 -0
  15. package/docs/agent/development/iteration-2.md +9 -0
  16. package/docs/agent/development/iteration-3.md +142 -0
  17. package/docs/agent/development/iteration-4.md +261 -0
  18. package/docs/agent/development/iteration-5.md +43 -0
  19. package/docs/agent/development/iteration-6.md +130 -0
  20. package/docs/agent/development/iteration-7.md +117 -0
  21. package/docs/agent/development/iteration-8-acceptance.md +68 -0
  22. package/docs/agent/development/iteration-8.md +116 -0
  23. package/docs/agent/development/iteration-9.md +44 -0
  24. package/docs/agent/development/playground-plan.md +143 -0
  25. package/docs/agent/development/prompt-contract.md +22 -0
  26. package/docs/agent/development/rc-1-acceptance.md +43 -0
  27. package/docs/agent/development/roadmap.md +259 -0
  28. package/docs/agent/development-guide.md +60 -0
  29. package/docs/agent/diagram-scenario.md +82 -0
  30. package/docs/agent/editing-contract.md +71 -0
  31. package/docs/agent/frontend-integration.md +66 -0
  32. package/docs/agent/project-scenario.md +89 -0
  33. package/docs/agent/quickstart.md +205 -0
  34. package/docs/agent/runtime-contract.md +132 -0
  35. package/docs/agent/theme-guide.md +59 -0
  36. package/docs/agent/zh-CN/README.md +26 -0
  37. package/docs/agent/zh-CN/charting-scenario.md +62 -0
  38. package/docs/agent/zh-CN/coding-agent-integration.md +36 -0
  39. package/docs/agent/zh-CN/development-guide.md +31 -0
  40. package/docs/agent/zh-CN/diagram-scenario.md +37 -0
  41. package/docs/agent/zh-CN/editing-contract.md +27 -0
  42. package/docs/agent/zh-CN/frontend-integration.md +31 -0
  43. package/docs/agent/zh-CN/project-scenario.md +40 -0
  44. package/docs/agent/zh-CN/quickstart.md +84 -0
  45. package/docs/agent/zh-CN/runtime-contract.md +85 -0
  46. package/docs/agent/zh-CN/theme-guide.md +50 -0
  47. package/docs/manifests/capabilities.json +79 -0
  48. package/docs/manifests/commands.json +30 -0
  49. package/docs/manifests/schemas.json +13 -0
  50. package/examples/agent-workflow.mjs +94 -0
  51. package/package.json +55 -0
  52. package/skills/ichartjs/SKILL.md +72 -0
  53. package/skills/ichartjs/agents/openai.yaml +4 -0
  54. package/skills/ichartjs/references/agent-contract.md +39 -0
  55. package/skills/ichartjs/references/chart-selection.md +22 -0
  56. package/src/capabilities.mjs +190 -0
  57. package/src/charts.mjs +234 -0
  58. package/src/command.mjs +93 -0
  59. package/src/data.mjs +75 -0
  60. package/src/diagram-interaction.mjs +195 -0
  61. package/src/diagram.mjs +135 -0
  62. package/src/edit-controller.mjs +192 -0
  63. package/src/edit.mjs +369 -0
  64. package/src/format.mjs +19 -0
  65. package/src/history.mjs +14 -0
  66. package/src/index.mjs +562 -0
  67. package/src/plugin.mjs +18 -0
  68. package/src/project-analytics.mjs +357 -0
  69. package/src/project-linking.mjs +72 -0
  70. package/src/project.mjs +370 -0
  71. package/src/recipes.mjs +18 -0
  72. package/src/renderer.mjs +77 -0
  73. package/src/scale.mjs +9 -0
  74. package/src/scene.mjs +18 -0
  75. package/src/schema.mjs +142 -0
  76. package/src/spec.mjs +127 -0
  77. package/src/theme.mjs +232 -0
  78. package/src/transforms.mjs +34 -0
  79. package/src/validation.mjs +106 -0
  80. package/types/index.d.ts +134 -0
package/src/schema.mjs ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * JSON-safe schema utilities and built-in Agent business data contracts.
3
+ * Defines editable fields, validation constraints, and structured issues.
4
+ */
5
+ export const schemaVersion = '1.0';
6
+ export const fieldTypes = ['string', 'number', 'boolean', 'date', 'enum', 'array', 'object'];
7
+ const forbiddenKeys = new Set(['__proto__', 'prototype', 'constructor']);
8
+
9
+ export function isRecord(value) {
10
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
11
+ }
12
+
13
+ export function copyJSON(value, seen = new Set()) {
14
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
15
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
16
+ if ((!Array.isArray(value) && !isRecord(value)) || seen.has(value)) throw new Error('Expected finite, acyclic JSON data.');
17
+ seen.add(value);
18
+ const result = Array.isArray(value) ? [] : {};
19
+ for (const key of Object.keys(value)) {
20
+ if (forbiddenKeys.has(key)) throw new Error(`Unsafe key: ${key}`);
21
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
22
+ if (!Object.hasOwn(descriptor, 'value')) throw new Error('Accessors are not JSON data.');
23
+ result[key] = copyJSON(descriptor.value, seen);
24
+ }
25
+ seen.delete(value);
26
+ return result;
27
+ }
28
+
29
+ export function issue(code, path, message, suggestion = 'Correct the input and validate again.', rule = null) {
30
+ return { code, path, rule, message, suggestion };
31
+ }
32
+
33
+ const identifier = { type: 'string', required: true, editable: false };
34
+ const status = { type: 'enum', values: ['todo', 'active', 'blocked', 'done'], default: 'todo', editable: true };
35
+ const position = { type: 'object', editable: true, properties: { x: { type: 'number', required: true }, y: { type: 'number', required: true } } };
36
+ const dependency = { anyOf: [
37
+ { type: 'string' },
38
+ { type: 'object', properties: {
39
+ id: { type: 'string', required: true },
40
+ type: { type: 'enum', values: ['finish-to-start', 'start-to-start', 'finish-to-finish', 'start-to-finish'] },
41
+ lag: { type: 'number' },
42
+ lead: { type: 'number' }
43
+ } }
44
+ ] };
45
+ const schema = (name, fields, rules = []) => ({ name, version: schemaVersion, key: 'id', fields: { id: identifier, ...fields }, rules });
46
+ const models = {
47
+ 'project-task': schema('project-task', {
48
+ name: { type: 'string', required: true, editable: true },
49
+ start: { type: 'date', required: true, editable: true },
50
+ end: { type: 'date', required: true, editable: true },
51
+ baselineStart: { type: 'date', editable: true },
52
+ baselineEnd: { type: 'date', editable: true },
53
+ actualStart: { type: 'date', editable: true },
54
+ actualEnd: { type: 'date', editable: true },
55
+ baselineDate: { type: 'date', editable: true },
56
+ actualDate: { type: 'date', editable: true },
57
+ progress: { type: 'number', min: 0, max: 100, default: 0, editable: true },
58
+ status,
59
+ owner: { type: 'string', editable: true },
60
+ resource: { type: 'string', editable: true },
61
+ capacity: { type: 'number', min: 0, editable: true },
62
+ load: { type: 'number', min: 0, editable: true },
63
+ points: { type: 'number', min: 0, editable: true },
64
+ sprint: { type: 'string', editable: true },
65
+ priority: { type: 'enum', values: ['low', 'medium', 'high', 'critical'], editable: true },
66
+ labels: { type: 'array', items: { type: 'string' }, default: [], editable: true },
67
+ dependencies: { type: 'array', items: dependency, default: [], unique: true, references: 'self', acyclic: true, editable: true }
68
+ }, [
69
+ { id: 'end-after-start', kind: 'compare', left: 'end', op: 'gte', right: 'start', message: 'End must not precede start.' },
70
+ { id: 'done-progress', kind: 'implies', if: { field: 'status', equals: 'done' }, then: { field: 'progress', equals: 100 }, message: 'Completed tasks require 100% progress.' }
71
+ ]),
72
+ 'timeline-event': schema('timeline-event', { title: { type: 'string', required: true, editable: true }, date: { type: 'date', required: true, editable: true }, description: { type: 'string', editable: true }, status }),
73
+ milestone: schema('milestone', { title: { type: 'string', required: true, editable: true }, date: { type: 'date', required: true, editable: true }, status }),
74
+ 'burndown-sample': schema('burndown-sample', { date: { type: 'date', required: true, editable: true }, remaining: { type: 'number', min: 0, required: true, editable: true }, scopeChange: { type: 'number', default: 0, editable: true }, forecast: { type: 'date', nullable: true, editable: false } }),
75
+ 'flow-node': schema('flow-node', { label: { type: 'string', required: true, editable: true }, position, size: { type: 'object', editable: true, properties: { width: { type: 'number', required: true, min: 1 }, height: { type: 'number', required: true, min: 1 } } }, laneId: { type: 'string', references: 'swimlane', editable: true }, groupId: { type: 'string', editable: false }, ports: { type: 'array', editable: false, items: { type: 'object', properties: { id: { type: 'string', required: true }, side: { type: 'enum', values: ['left', 'right', 'top', 'bottom'] }, offset: { type: 'number', min: 0, max: 1 } } } }, description: { type: 'string', editable: true }, status }),
76
+ 'flow-edge': schema('flow-edge', {
77
+ from: { type: 'string', references: 'flow-node', required: true, editable: true },
78
+ to: { type: 'string', references: 'flow-node', required: true, editable: true },
79
+ fromPort: { type: 'string', editable: true },
80
+ toPort: { type: 'string', editable: true },
81
+ label: { type: 'string', editable: true },
82
+ status,
83
+ routing: { type: 'enum', values: ['straight', 'orthogonal', 'curved'], editable: true }
84
+ }),
85
+ swimlane: schema('swimlane', { label: { type: 'string', required: true, editable: true } })
86
+ };
87
+
88
+ export function getBusinessSchema(name) {
89
+ if (!Object.hasOwn(models, name)) throw new Error(`Unknown business model: ${name}`);
90
+ return copyJSON(models[name]);
91
+ }
92
+
93
+ export function validateDataSchema(input) {
94
+ const errors = [];
95
+ let result;
96
+ try { result = copyJSON(input); } catch (error) { return { valid: false, errors: [issue('INVALID_JSON', 'schema', error.message)], schema: null }; }
97
+ if (!isRecord(result)) return { valid: false, errors: [issue('INVALID_SCHEMA', 'schema', 'Schema must be an object.')], schema: null };
98
+ if (result.version !== schemaVersion) errors.push(issue('SCHEMA_VERSION', 'schema.version', 'Unsupported schema version.', 'Use 1.0; migration is not automatic.'));
99
+ if (typeof result.name !== 'string' || !result.name) errors.push(issue('SCHEMA_NAME', 'schema.name', 'Schema name is required.'));
100
+ const fields = isRecord(result.fields) ? result.fields : {};
101
+ if (typeof result.key !== 'string' || fields[result.key]?.type !== 'string' || !fields[result.key]?.required || fields[result.key]?.editable !== false) errors.push(issue('SCHEMA_KEY', 'schema.key', 'The primary key must be a required read-only string field.'));
102
+ const allowed = ['type', 'anyOf', 'required', 'editable', 'agentEditable', 'nullable', 'default', 'min', 'max', 'values', 'items', 'properties', 'references', 'acyclic', 'unique'];
103
+ const checkField = (field, path, depth = 0) => {
104
+ if (!isRecord(field) || depth > 12) { errors.push(issue('INVALID_FIELD', path, 'Invalid or excessively nested field definition.')); return; }
105
+ Object.keys(field).filter(key => !allowed.includes(key)).forEach(key => errors.push(issue('UNSUPPORTED_CONSTRAINT', `${path}.${key}`, `Constraint ${key} is not implemented; it will not be ignored.`)));
106
+ if (field.anyOf !== undefined) {
107
+ if (!Array.isArray(field.anyOf) || field.anyOf.length < 2) errors.push(issue('FIELD_UNION', path, 'anyOf requires at least two field definitions.'));
108
+ else field.anyOf.forEach((option, index) => checkField(option, `${path}.anyOf.${index}`, depth + 1));
109
+ } else if (!fieldTypes.includes(field.type)) errors.push(issue('FIELD_TYPE', path, 'Unsupported field type.'));
110
+ for (const key of ['required', 'editable', 'agentEditable', 'nullable', 'acyclic', 'unique']) if (field[key] !== undefined && typeof field[key] !== 'boolean') errors.push(issue('FIELD_FLAG', `${path}.${key}`, 'Expected a boolean.'));
111
+ for (const key of ['min', 'max']) if (field[key] !== undefined && (field.type !== 'number' || typeof field[key] !== 'number')) errors.push(issue('FIELD_RANGE', `${path}.${key}`, 'Numeric limits apply to number fields only.'));
112
+ if (field.min > field.max) errors.push(issue('FIELD_RANGE', path, 'min exceeds max.'));
113
+ if (field.type === 'enum' && (!Array.isArray(field.values) || !field.values.length || !field.values.every(value => typeof value === 'string'))) errors.push(issue('ENUM_VALUES', path, 'Enum values must be a nonempty string array.'));
114
+ if (field.type === 'array') checkField(field.items, `${path}.items`, depth + 1);
115
+ if (field.type === 'object') {
116
+ if (!isRecord(field.properties)) errors.push(issue('OBJECT_PROPERTIES', path, 'Object fields need explicit properties.'));
117
+ else Object.entries(field.properties).forEach(([key, child]) => checkField(child, `${path}.properties.${key}`, depth + 1));
118
+ }
119
+ const referenceItem = field.type === 'array' && (field.items?.type === 'string' || field.items?.anyOf?.some(option => option.type === 'string'));
120
+ if (field.references !== undefined && (typeof field.references !== 'string' || !field.references || !(field.type === 'string' || referenceItem))) errors.push(issue('REFERENCE_SCHEMA', path, 'References require string IDs or arrays containing string IDs.'));
121
+ if (field.acyclic && (field.type !== 'array' || field.references !== 'self')) errors.push(issue('ACYCLIC_SCHEMA', path, 'Cycle checks require self-referencing arrays.'));
122
+ };
123
+ Object.entries(fields).forEach(([name, field]) => checkField(field, `schema.fields.${name}`));
124
+ const rules = result.rules ?? [], ruleIds = new Set();
125
+ if (!Array.isArray(rules)) errors.push(issue('INVALID_RULES', 'schema.rules', 'Rules must be declarative objects, not executable expressions.'));
126
+ else rules.forEach((rule, index) => {
127
+ const path = `schema.rules.${index}`;
128
+ if (!isRecord(rule) || typeof rule.id !== 'string' || !rule.id || ruleIds.has(rule.id)) { errors.push(issue('INVALID_RULE', path, 'Each rule requires a unique string ID.')); return; }
129
+ ruleIds.add(rule.id);
130
+ if (rule.kind === 'compare') {
131
+ if (!['gte', 'gt', 'lte', 'lt', 'eq'].includes(rule.op) || !fields[rule.left] || !fields[rule.right] || fields[rule.left].type !== fields[rule.right].type || !['date', 'number'].includes(fields[rule.left].type)) errors.push(issue('INVALID_RULE', path, 'Comparisons need matching number or date fields and a supported operator.'));
132
+ } else if (rule.kind === 'implies') {
133
+ if (![rule.if, rule.then].every(term => isRecord(term) && fields[term.field] && Object.hasOwn(term, 'equals'))) errors.push(issue('INVALID_RULE', path, 'Implications require if/then field and equals objects.'));
134
+ } else errors.push(issue('UNSUPPORTED_RULE', path, 'Only compare and implies rules are implemented.'));
135
+ });
136
+ return { valid: errors.length === 0, errors, schema: result };
137
+ }
138
+
139
+ export function inspectDataSchema(input) {
140
+ const result = validateDataSchema(input);
141
+ return { ...result, models: Object.keys(models), fieldTypes: [...fieldTypes], rules: ['compare', 'implies'], normalization: 'explicit-only', progressUnit: 'percent-0-100' };
142
+ }
package/src/spec.mjs ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Chart Spec defaults, normalization, and top-level validation.
3
+ * The normalized Spec is the stable contract shared by all renderers.
4
+ */
5
+ import { chartProfiles } from './capabilities.mjs';
6
+ import { themeModes, themePalettes, themePresets } from './theme.mjs';
7
+
8
+ const chartTypes = new Set(Object.keys(chartProfiles));
9
+
10
+ const defaults = {
11
+ version: '2.0',
12
+ renderer: 'canvas',
13
+ width: 640,
14
+ height: 360,
15
+ padding: { top: 48, right: 24, bottom: 48, left: 56 },
16
+ colors: ['#2563eb', '#16a34a', '#f59e0b', '#dc2626', '#7c3aed', '#0891b2'],
17
+ background: '#ffffff',
18
+ theme: 'auto',
19
+ title: { text: '', subtitle: '' },
20
+ legend: { visible: true, position: 'top' },
21
+ grid: { visible: true },
22
+ labels: { enabled: false },
23
+ branding: { enabled: true },
24
+ interaction: { tooltip: true, hover: true, click: true, crosshair: false, zoom: false, brush: false, keyboard: true }
25
+ ,responsive: [], accessibility: { enabled: false }, editing: { enabled: false, mode: 'command', requireConfirmation: true, allowDelete: false, allowStructuralChanges: false }, xAxis: {}, yAxis: {}
26
+ };
27
+
28
+ function clone(value) {
29
+ return value == null ? value : JSON.parse(JSON.stringify(value));
30
+ }
31
+
32
+ function merge(base, extra) {
33
+ const result = clone(base);
34
+ Object.keys(extra || {}).forEach(key => {
35
+ if (extra[key] && typeof extra[key] === 'object' && !Array.isArray(extra[key]) && result[key] && typeof result[key] === 'object') {
36
+ result[key] = merge(result[key], extra[key]);
37
+ } else if (extra[key] !== undefined) {
38
+ result[key] = clone(extra[key]);
39
+ }
40
+ });
41
+ return result;
42
+ }
43
+
44
+ function dependencyId(dependency) {
45
+ return typeof dependency === 'string' ? dependency : dependency && typeof dependency === 'object' && typeof dependency.id === 'string' ? dependency.id : null;
46
+ }
47
+
48
+ function dependencyErrors(rows) {
49
+ const errors = [], ids = new Set(rows.map(row => row.id).filter(Boolean)), graph = new Map(rows.map(row => [row.id, (row.dependencies || []).map(dependencyId).filter(Boolean)]));
50
+ rows.forEach((row, index) => (row.dependencies || []).forEach((dependency, dependencyIndex) => {
51
+ const id = dependencyId(dependency);
52
+ if (!id) errors.push({ code: 'INVALID_DEPENDENCY', path: `data.values[${index}].dependencies[${dependencyIndex}]`, message: 'Dependency must be a task id or an object with an id.', suggestion: 'Use a string id or { id, type, lag, lead }.' });
53
+ else if (!ids.has(id)) errors.push({ code: 'MISSING_DEPENDENCY', path: `data.values[${index}].dependencies`, message: `Task ${row.id || index} references missing task ${id}.`, suggestion: 'Use an existing task id.' });
54
+ }));
55
+ const visiting = new Set(), visited = new Set();
56
+ function visit(id) { if (visiting.has(id)) return true; if (visited.has(id)) return false; visiting.add(id); const cycle = (graph.get(id) || []).some(visit); visiting.delete(id); visited.add(id); return cycle; }
57
+ graph.forEach((_, id) => { if (visit(id)) errors.push({ code: 'CYCLIC_DEPENDENCY', path: 'data.values', message: 'Gantt dependencies contain a cycle.', suggestion: 'Dependencies must form a directed acyclic graph.' }); });
58
+ return errors;
59
+ }
60
+
61
+ export function normalizeSpec(input = {}) {
62
+ const spec = merge(defaults, input);
63
+ if (spec.branding === false) spec.branding = { enabled: false };
64
+ else if (spec.branding === true) spec.branding = { enabled: true };
65
+ else if (spec.branding == null) spec.branding = { enabled: true };
66
+ else if (typeof spec.branding === 'object' && !Array.isArray(spec.branding)) {
67
+ if (spec.branding.enabled === undefined) spec.branding = { ...spec.branding, enabled: true };
68
+ }
69
+ if (!spec.data) spec.data = { values: [] };
70
+ if (['flow', 'swimlane'].includes(spec.type) && spec.nodes && !spec.data.values) spec.data.values = [];
71
+ if (Array.isArray(spec.data)) spec.data = { values: spec.data };
72
+ if (!spec.encoding) spec.encoding = {};
73
+ if (['pie', 'funnel', 'gauge'].includes(spec.type) && !spec.encoding.category) spec.encoding.category = { field: 'name', type: 'category' };
74
+ if (['pie', 'funnel', 'gauge'].includes(spec.type) && !spec.encoding.value) spec.encoding.value = { field: 'value', type: 'quantitative' };
75
+ if (spec.type === 'scatter' && !input.encoding?.x) spec.encoding.x = { field: 'x', type: 'quantitative' };
76
+ if (spec.type === 'scatter' && !input.encoding?.y) spec.encoding.y = { field: 'y', type: 'quantitative' };
77
+ 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' }; }
78
+ if (!['pie', 'funnel', 'gauge', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane'].includes(spec.type)) {
79
+ spec.encoding.x = spec.encoding.x || { field: 'name', type: 'category' };
80
+ spec.encoding.y = spec.encoding.y || { field: 'value', type: 'quantitative' };
81
+ }
82
+ return spec;
83
+ }
84
+
85
+ export function validateSpec(input) {
86
+ const spec = normalizeSpec(input);
87
+ const errors = [], warnings = [], normalizations = [];
88
+ 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().' });
89
+ if (!['canvas', 'svg', 'auto'].includes(spec.renderer)) errors.push({ code: 'INVALID_RENDERER', path: 'renderer', message: `Unsupported renderer: ${spec.renderer}`, suggestion: 'Use canvas, svg, or auto.' });
90
+ if (typeof spec.theme === 'string' && !themeModes.includes(spec.theme) && !themePresets.includes(spec.theme)) errors.push({ code: 'INVALID_THEME', path: 'theme', message: `Unsupported theme: ${spec.theme}`, suggestion: 'Use auto, light, dark, contrast, or a named style preset.' });
91
+ if (spec.theme && typeof spec.theme === 'object') {
92
+ if (spec.theme.mode && !themeModes.includes(spec.theme.mode)) errors.push({ code: 'INVALID_THEME_MODE', path: 'theme.mode', message: `Unsupported theme mode: ${spec.theme.mode}`, suggestion: `Use ${themeModes.join(', ')}.` });
93
+ if (spec.theme.preset && !themePresets.includes(spec.theme.preset)) errors.push({ code: 'INVALID_THEME_PRESET', path: 'theme.preset', message: `Unsupported theme preset: ${spec.theme.preset}`, suggestion: `Use ${themePresets.join(', ')}.` });
94
+ if (spec.theme.palette && !themePalettes.includes(spec.theme.palette)) errors.push({ code: 'INVALID_THEME_PALETTE', path: 'theme.palette', message: `Unsupported theme palette: ${spec.theme.palette}`, suggestion: `Use ${themePalettes.join(', ')}.` });
95
+ }
96
+ 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.' });
97
+ 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.' });
98
+ 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".' });
99
+ 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.' });
100
+ 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.' });
101
+ 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.' });
102
+ if (['flow', 'swimlane'].includes(spec.type) && !Array.isArray(spec.nodes) && !Array.isArray(spec.data?.nodes)) errors.push({ code: 'INVALID_NODES', path: 'nodes', message: 'Flow and swimlane charts require nodes.', suggestion: 'Pass nodes on the Spec or in data.nodes.' });
103
+ if (['gantt', 'timeline', 'milestone'].includes(spec.type)) {
104
+ const rows = spec.data.values, dateKey = spec.type === 'gantt' ? 'start/end' : 'date';
105
+ if (!rows.some(row => row.start || row.end || row.date)) errors.push({ code: 'INVALID_INTERVALS', path: 'data.values', message: `${spec.type} rows require ${dateKey} dates.`, suggestion: 'Add ISO date values to each row.' });
106
+ 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.' }); });
107
+ }
108
+ if (spec.type === 'gantt') errors.push(...dependencyErrors(spec.data.values));
109
+ if (spec.type === 'pie' && spec.data.values.length > 8) warnings.push({ code: 'HIGH_CARDINALITY_PIE', path: 'data.values', message: `Pie contains ${spec.data.values.length} categories.`, expected: '8 or fewer categories', suggestion: 'Use bar/column or group smaller categories.' });
110
+ 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.' });
111
+ const supportedInteractions = chartProfiles[spec.type]?.interactions || [];
112
+ 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.' }); });
113
+ if (spec.branding != null && typeof spec.branding !== 'boolean' && !(spec.branding && typeof spec.branding === 'object')) {
114
+ errors.push({ code: 'INVALID_BRANDING', path: 'branding', message: 'branding must be a boolean or a { enabled: boolean } object.', suggestion: 'Use branding: true, branding: false, or branding: { enabled: false }.' });
115
+ } else if (spec.branding && typeof spec.branding === 'object') {
116
+ const keys = new Set(Object.keys(spec.branding));
117
+ if (keys.size > 1 || !keys.has('enabled')) {
118
+ errors.push({ code: 'UNSUPPORTED_BRANDING_OPTIONS', path: 'branding', message: 'This iteration of branding only supports the enabled option.', expected: ['enabled'], suggestion: 'Custom text, links, position, and font are intentionally not exposed in the current version.' });
119
+ } else if (typeof spec.branding.enabled !== 'boolean') {
120
+ errors.push({ code: 'INVALID_BRANDING_ENABLED', path: 'branding.enabled', message: 'branding.enabled must be a boolean.', suggestion: 'Use true to show the Powered by iChart.js signature or false to hide.' });
121
+ }
122
+ }
123
+ ['width', 'height'].forEach(key => { if (!(Number(spec[key]) > 0)) errors.push({ code: 'INVALID_DIMENSION', path: key, message: `${key} must be greater than zero.`, suggestion: `Set a positive ${key}.` }); });
124
+ return { valid: errors.length === 0, errors, warnings, normalizations, spec };
125
+ }
126
+
127
+ export { chartTypes };
package/src/theme.mjs ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Renderer-neutral visual style presets, palettes, and resolution helpers.
3
+ */
4
+
5
+ export const themeModes = ['auto', 'light', 'dark', 'contrast'];
6
+ export const themePresets = ['auto', 'analysis', 'dashboard', 'report', 'presentation', 'project', 'diagram'];
7
+ export const themePalettes = ['auto', 'categorical', 'sequential', 'diverging', 'status'];
8
+
9
+ const fontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
10
+
11
+ const modes = {
12
+ light: {
13
+ background: '#ffffff', surface: '#ffffff', text: '#172033', muted: '#536274', axis: '#667085', grid: '#d9e0ea', border: '#c5cfdb', focus: '#1d4ed8', selection: '#1d4ed8', missing: '#d7dee8',
14
+ categorical: ['#6929c4', '#0072c3', '#007d79', '#9f1853', '#a2191f', '#198038', '#002d9c', '#8a3800'],
15
+ sequential: ['#edf5ff', '#d0e2ff', '#a6c8ff', '#78a9ff', '#4589ff', '#0f62fe', '#0043ce', '#002d9c'],
16
+ diverging: ['#491d8b', '#8a3ffc', '#be95ff', '#e8daff', '#f8fafc', '#d9fbfb', '#3ddbd9', '#009d9a', '#005d5d'],
17
+ status: { success: '#198038', warning: '#b28600', danger: '#da1e28', info: '#0072c3', neutral: '#697586' }
18
+ },
19
+ dark: {
20
+ background: '#0f172a', surface: '#172033', text: '#f8fafc', muted: '#cbd5e1', axis: '#c2cad6', grid: '#344054', border: '#475467', focus: '#84adff', selection: '#84adff', missing: '#475467',
21
+ categorical: ['#be95ff', '#33b1ff', '#3ddbd9', '#ff7eb6', '#fa4d56', '#6fdc8c', '#78a9ff', '#ffb784'],
22
+ sequential: ['#001141', '#001d6c', '#002d9c', '#0043ce', '#0f62fe', '#4589ff', '#78a9ff', '#d0e2ff'],
23
+ diverging: ['#be95ff', '#a56eff', '#8a3ffc', '#6929c4', '#344054', '#007d79', '#009d9a', '#3ddbd9', '#9ef0f0'],
24
+ status: { success: '#42be65', warning: '#f1c21b', danger: '#ff8389', info: '#33b1ff', neutral: '#98a2b3' }
25
+ },
26
+ contrast: {
27
+ background: '#000000', surface: '#000000', text: '#ffffff', muted: '#ffffff', axis: '#ffffff', grid: '#8c8c8c', border: '#ffffff', focus: '#ffff00', selection: '#ffff00', missing: '#666666',
28
+ categorical: ['#ffff00', '#00ffff', '#ff7eb6', '#ffffff', '#7fff00', '#ff8c00'],
29
+ sequential: ['#1a1a1a', '#4d4d4d', '#808080', '#b3b3b3', '#e6e6e6', '#ffffff'],
30
+ diverging: ['#ff00ff', '#ff7eb6', '#ffffff', '#00ffff', '#007fff'],
31
+ status: { success: '#7fff00', warning: '#ffff00', danger: '#ff4d4d', info: '#00ffff', neutral: '#ffffff' }
32
+ }
33
+ };
34
+
35
+ const presets = {
36
+ analysis: {
37
+ typography: { title: [16, 600, 1.25], subtitle: [13, 400, 1.4], axis: [12, 400, 1.4], legend: [12, 500, 1.4], label: [12, 500, 1.35], tooltip: [12, 400, 1.45], metric: [24, 600, 1.15] },
38
+ layout: { padding: { top: 48, right: 24, bottom: 48, left: 56 }, spacing: 8, legendGap: 12, axisGap: 10, density: 'comfortable', minimumTargetSize: 24 },
39
+ marks: { lineWidth: 2, selectedLineWidth: 3, radius: 5, opacity: 1, gridWidth: 1, dashPatterns: [[], [6, 4], [2, 3]] }
40
+ },
41
+ dashboard: {
42
+ typography: { title: [15, 600, 1.25], subtitle: [12, 400, 1.4], axis: [12, 400, 1.35], legend: [12, 500, 1.35], label: [12, 500, 1.3], tooltip: [12, 400, 1.4], metric: [24, 650, 1.1] },
43
+ layout: { padding: { top: 42, right: 18, bottom: 42, left: 50 }, spacing: 6, legendGap: 8, axisGap: 8, density: 'compact', minimumTargetSize: 24 },
44
+ marks: { lineWidth: 2, selectedLineWidth: 3, radius: 4, opacity: 1, gridWidth: 1, dashPatterns: [[], [5, 3], [2, 2]] }
45
+ },
46
+ report: {
47
+ typography: { title: [17, 650, 1.25], subtitle: [13, 400, 1.45], axis: [12, 400, 1.45], legend: [12, 500, 1.4], label: [12, 500, 1.4], tooltip: [12, 400, 1.45], metric: [24, 650, 1.15] },
48
+ layout: { padding: { top: 54, right: 28, bottom: 52, left: 60 }, spacing: 8, legendGap: 14, axisGap: 12, density: 'comfortable', minimumTargetSize: 24 },
49
+ marks: { lineWidth: 2, selectedLineWidth: 3, radius: 4, opacity: 1, gridWidth: 1, dashPatterns: [[], [7, 4], [2, 3]] }
50
+ },
51
+ presentation: {
52
+ typography: { title: [20, 650, 1.2], subtitle: [14, 400, 1.45], axis: [13, 450, 1.4], legend: [13, 550, 1.4], label: [13, 550, 1.35], tooltip: [13, 400, 1.45], metric: [28, 650, 1.1] },
53
+ layout: { padding: { top: 62, right: 34, bottom: 58, left: 66 }, spacing: 10, legendGap: 16, axisGap: 14, density: 'spacious', minimumTargetSize: 28 },
54
+ marks: { lineWidth: 3, selectedLineWidth: 4, radius: 6, opacity: 1, gridWidth: 1, dashPatterns: [[], [8, 5], [3, 4]] }
55
+ },
56
+ project: {
57
+ typography: { title: [16, 650, 1.25], subtitle: [13, 400, 1.4], axis: [12, 450, 1.4], legend: [12, 550, 1.4], label: [12, 500, 1.35], tooltip: [12, 400, 1.45], metric: [24, 650, 1.15] },
58
+ layout: { padding: { top: 50, right: 26, bottom: 52, left: 64 }, spacing: 8, legendGap: 12, axisGap: 10, density: 'comfortable', minimumTargetSize: 24 },
59
+ marks: { lineWidth: 2, selectedLineWidth: 3, radius: 5, opacity: 1, gridWidth: 1, dashPatterns: [[], [6, 4], [2, 3]] }
60
+ },
61
+ diagram: {
62
+ typography: { title: [16, 650, 1.25], subtitle: [13, 400, 1.4], axis: [12, 400, 1.4], legend: [12, 500, 1.4], label: [12, 550, 1.35], tooltip: [12, 400, 1.45], metric: [24, 650, 1.15] },
63
+ layout: { padding: { top: 50, right: 28, bottom: 44, left: 58 }, spacing: 8, legendGap: 12, axisGap: 10, density: 'comfortable', minimumTargetSize: 24 },
64
+ marks: { lineWidth: 1.75, selectedLineWidth: 3, radius: 6, opacity: 1, gridWidth: 1, dashPatterns: [[], [6, 4], [2, 3]] }
65
+ }
66
+ };
67
+
68
+ const projectTypes = new Set(['gantt', 'timeline', 'milestone', 'burndown']);
69
+ const diagramTypes = new Set(['flow', 'swimlane']);
70
+ const structuredKeys = new Set(['preset', 'mode', 'palette', 'tokens']);
71
+
72
+ function clone(value) { return value == null ? value : JSON.parse(JSON.stringify(value)); }
73
+
74
+ function merge(base, extra) {
75
+ const result = clone(base) || {};
76
+ Object.entries(extra || {}).forEach(([key, value]) => {
77
+ if (value && typeof value === 'object' && !Array.isArray(value) && result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) result[key] = merge(result[key], value);
78
+ else if (value !== undefined) result[key] = clone(value);
79
+ });
80
+ return result;
81
+ }
82
+
83
+ function role(values) {
84
+ const [size, weight, lineHeight] = values;
85
+ return { size, weight, lineHeight, font: `${weight} ${size}px ${fontFamily}` };
86
+ }
87
+
88
+ function requestedTheme(theme) {
89
+ if (typeof theme === 'string') {
90
+ if (themeModes.includes(theme)) return { mode: theme, preset: 'auto', palette: 'auto', overrides: {} };
91
+ if (themePresets.includes(theme)) return { mode: 'auto', preset: theme, palette: 'auto', overrides: {} };
92
+ }
93
+ if (theme && typeof theme === 'object') {
94
+ const overrides = Object.fromEntries(Object.entries(theme).filter(([key]) => !structuredKeys.has(key)));
95
+ return { mode: theme.mode || 'auto', preset: theme.preset || 'auto', palette: theme.palette || 'auto', overrides: merge(overrides, theme.tokens || {}) };
96
+ }
97
+ return { mode: 'auto', preset: 'auto', palette: 'auto', overrides: {} };
98
+ }
99
+
100
+ function preferredMode(mode, options = {}) {
101
+ if (mode !== 'auto') return themeModes.includes(mode) ? mode : 'light';
102
+ if (options.preferredColorScheme === 'dark') return 'dark';
103
+ if (options.preferredColorScheme === 'light') return 'light';
104
+ if (typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches) return 'dark';
105
+ return 'light';
106
+ }
107
+
108
+ function sourceRows(spec = {}) {
109
+ const data = Array.isArray(spec.data) ? spec.data : spec.data?.values;
110
+ return Array.isArray(data) ? data : Array.isArray(spec.nodes) ? spec.nodes : [];
111
+ }
112
+
113
+ function automaticPreset(spec = {}, options = {}) {
114
+ const context = options.context || spec.context || spec.intent;
115
+ if (themePresets.includes(context) && context !== 'auto') return context;
116
+ if (['print', 'export', 'weekly-report', 'reporting'].includes(context)) return 'report';
117
+ if (['slides', 'story', 'presentation'].includes(context)) return 'presentation';
118
+ if (['monitoring', 'dashboard'].includes(context)) return 'dashboard';
119
+ if (projectTypes.has(spec.type)) return 'project';
120
+ if (diagramTypes.has(spec.type)) return 'diagram';
121
+ return 'analysis';
122
+ }
123
+
124
+ function automaticPalette(spec = {}) {
125
+ const rows = sourceRows(spec);
126
+ const fields = new Set(rows.flatMap(row => Object.keys(row || {})).map(field => field.toLowerCase()));
127
+ if (['status', 'risk', 'priority', 'severity', 'health'].some(field => fields.has(field))) return 'status';
128
+ if (spec.type === 'heatmap' || spec.encoding?.color?.type === 'quantitative') return 'sequential';
129
+ const inferredMeasures = Object.keys(rows[0] || {}).filter(field => rows.some(row => row?.[field] != null && row?.[field] !== '' && Number.isFinite(Number(row[field])))).map(field => ({ field }));
130
+ const measures = Array.isArray(spec.encoding?.y) ? spec.encoding.y : [spec.encoding?.y || spec.encoding?.value].filter(Boolean);
131
+ if (!measures.length) measures.push(...inferredMeasures);
132
+ const values = rows.flatMap(row => measures.map(encoding => Number(row?.[encoding?.field])).filter(Number.isFinite));
133
+ if (values.some(value => value < 0) && values.some(value => value > 0)) return 'diverging';
134
+ return 'categorical';
135
+ }
136
+
137
+ function categoryCount(spec = {}) {
138
+ const rows = sourceRows(spec);
139
+ const field = spec.encoding?.x?.field || spec.encoding?.category?.field || 'name';
140
+ return new Set(rows.map(row => row?.[field]).filter(value => value != null)).size;
141
+ }
142
+
143
+ export function planStyle(spec = {}, options = {}) {
144
+ const request = requestedTheme(options.theme ?? spec.theme);
145
+ const preset = request.preset === 'auto' ? automaticPreset(spec, options) : request.preset;
146
+ const palette = request.palette === 'auto' ? automaticPalette(spec) : request.palette;
147
+ const mode = themeModes.includes(request.mode) ? request.mode : 'auto';
148
+ const resolvedMode = preferredMode(mode, options);
149
+ const reasons = [`${preset} matches the ${spec.type || 'general'} visualization context.`, `${palette} matches the detected data semantics.`, `${resolvedMode} is the resolved color mode.`];
150
+ const warnings = [];
151
+ const categories = categoryCount(spec);
152
+ if (palette === 'categorical' && categories > 6) warnings.push({ code: 'HIGH_CARDINALITY_COLOR', path: 'theme.palette', message: `${categories} categories exceed the recommended six-color comparison set.`, suggestion: 'Group categories or add labels, shapes, or line patterns.' });
153
+ return { version: '1.0', preset, mode, resolvedMode, palette, reasons, warnings, userOverride: request.preset !== 'auto' || request.mode !== 'auto' || request.palette !== 'auto' };
154
+ }
155
+
156
+ function channel(value) {
157
+ const text = String(value || '').replace('#', '');
158
+ if (!/^[0-9a-f]{6}$/i.test(text)) return null;
159
+ return [0, 2, 4].map(index => parseInt(text.slice(index, index + 2), 16) / 255).map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
160
+ }
161
+
162
+ function luminance(value) {
163
+ const rgb = channel(value);
164
+ return rgb ? 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] : null;
165
+ }
166
+
167
+ export function contrastRatio(foreground, background) {
168
+ const first = luminance(foreground), second = luminance(background);
169
+ if (first == null || second == null) return null;
170
+ return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
171
+ }
172
+
173
+ export function validateThemeContrast(theme) {
174
+ const checks = [['text', theme.text, 4.5], ['muted', theme.muted, 4.5], ['axis', theme.axis, 3], ['focus', theme.focus, 3]];
175
+ return checks.flatMap(([token, color, minimum]) => {
176
+ const ratio = contrastRatio(color, theme.background);
177
+ return ratio != null && ratio < minimum ? [{ code: 'INSUFFICIENT_THEME_CONTRAST', path: `theme.${token}`, message: `${token} contrast ${ratio.toFixed(2)}:1 is below ${minimum}:1.`, expected: minimum }] : [];
178
+ });
179
+ }
180
+
181
+ export function resolveTheme(theme, options = {}) {
182
+ const request = requestedTheme(theme);
183
+ const plan = planStyle({ ...options, theme }, { ...options, theme });
184
+ const mode = modes[plan.resolvedMode] || modes.light;
185
+ const preset = presets[plan.preset] || presets.analysis;
186
+ const typography = Object.fromEntries(Object.entries(preset.typography).map(([key, value]) => [key, role(value)]));
187
+ const colors = plan.palette === 'sequential' ? mode.sequential : plan.palette === 'diverging' ? mode.diverging : plan.palette === 'status' ? Object.values(mode.status) : mode.categorical;
188
+ const resolved = {
189
+ name: `${plan.preset}-${plan.resolvedMode}`,
190
+ mode: plan.mode,
191
+ resolvedMode: plan.resolvedMode,
192
+ preset: plan.preset,
193
+ palette: plan.palette,
194
+ background: mode.background,
195
+ surface: mode.surface,
196
+ text: mode.text,
197
+ muted: mode.muted,
198
+ axis: mode.axis,
199
+ grid: mode.grid,
200
+ border: mode.border,
201
+ focus: mode.focus,
202
+ selection: mode.selection,
203
+ colors: [...colors],
204
+ palettes: { categorical: [...mode.categorical], sequential: [...mode.sequential], diverging: [...mode.diverging], status: { ...mode.status }, missing: mode.missing },
205
+ status: { ...mode.status },
206
+ typography,
207
+ layout: clone(preset.layout),
208
+ marks: clone(preset.marks),
209
+ reasons: [...plan.reasons],
210
+ warnings: [...plan.warnings]
211
+ };
212
+ const output = merge(resolved, request.overrides);
213
+ output.warnings = [...(output.warnings || []), ...validateThemeContrast(output)];
214
+ return output;
215
+ }
216
+
217
+ export const themes = {
218
+ light: resolveTheme('light'),
219
+ dark: resolveTheme('dark'),
220
+ contrast: resolveTheme('contrast')
221
+ };
222
+
223
+ export const styleCapabilities = {
224
+ modes: [...themeModes],
225
+ presets: [...themePresets],
226
+ palettes: [...themePalettes],
227
+ switchable: true,
228
+ automatic: true,
229
+ contrast: { normalText: 4.5, largeText: 3, essentialGraphics: 3 },
230
+ minimumTextSize: 12,
231
+ recommendedCategoricalColors: 6
232
+ };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Deterministic standalone transforms used by Specs and data pipelines.
3
+ */
4
+ function rowsFrom(input) {
5
+ if (Array.isArray(input)) return input.map(row => ({ ...row }));
6
+ if (input && Array.isArray(input.values)) return input.values.map(row => ({ ...row }));
7
+ return [];
8
+ }
9
+
10
+ export function binData(input, options = {}) {
11
+ const rows = rowsFrom(input), field = options.field || 'value', output = options.output || 'count';
12
+ const valid = [], warnings = [];
13
+ rows.forEach((row, index) => { const value = Number(row[field]); if (Number.isFinite(value)) valid.push({ value, index }); else warnings.push({ code: 'INVALID_BIN_VALUE', path: `rows.${index}.${field}`, message: 'Bin input must be numeric.' }); });
14
+ if (!valid.length) return { rows: [], warnings, assumptions: ['Bins include the lower bound and exclude the upper bound, except the final bin.'] };
15
+ const sourceMin = Math.min(...valid.map(item => item.value)), sourceMax = Math.max(...valid.map(item => item.value));
16
+ const extent = Array.isArray(options.extent) && options.extent.length === 2 ? options.extent.map(Number) : [sourceMin, sourceMax];
17
+ const min = extent[0], max = extent[1];
18
+ const thresholdCount = Math.max(1, Math.round(Number(options.thresholds) || Math.ceil(Math.sqrt(valid.length))));
19
+ const step = Number(options.step) > 0 ? Number(options.step) : (max - min || 1) / thresholdCount;
20
+ const count = Math.max(1, Math.ceil((max - min || step) / step));
21
+ const bins = Array.from({ length: count }, (_, index) => ({ binStart: min + index * step, binEnd: index === count - 1 ? max : min + (index + 1) * step, [output]: 0, sourceIndices: [] }));
22
+ valid.forEach(item => { if (item.value < min || item.value > max) return; const index = Math.min(count - 1, Math.max(0, Math.floor((item.value - min) / step))); bins[index][output] += 1; bins[index].sourceIndices.push(item.index); });
23
+ return { rows: bins.map(bin => ({ ...bin, name: `${Number(bin.binStart.toFixed(6))}–${Number(bin.binEnd.toFixed(6))}`, value: bin[output] })), warnings, assumptions: ['Bins include the lower bound and exclude the upper bound, except the final bin.'] };
24
+ }
25
+
26
+ export function applyTransforms(input, transforms = []) {
27
+ let rows = rowsFrom(input), warnings = [], assumptions = [];
28
+ for (const transform of Array.isArray(transforms) ? transforms : [transforms]) {
29
+ if (transform?.type !== 'bin') continue;
30
+ const result = binData(rows, transform);
31
+ rows = result.rows; warnings = warnings.concat(result.warnings); assumptions = assumptions.concat(result.assumptions);
32
+ }
33
+ return { rows, warnings, assumptions };
34
+ }