@taylorwong/ichartjs 2.0.5 → 2.0.7

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 (46) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +11 -4
  3. package/docs/agent/README.md +3 -1
  4. package/docs/agent/coding-agent-integration.md +6 -2
  5. package/docs/agent/development/iteration-11.md +44 -0
  6. package/docs/agent/development/iteration-12.md +101 -0
  7. package/docs/agent/development/roadmap.md +29 -3
  8. package/docs/agent/development-guide.md +1 -1
  9. package/docs/agent/diagram-scenario.md +39 -11
  10. package/docs/agent/editing-contract.md +5 -0
  11. package/docs/agent/frontend-integration.md +1 -1
  12. package/docs/agent/runtime-contract.md +11 -1
  13. package/docs/agent/theme-guide.md +63 -0
  14. package/docs/agent/usage-scenarios.md +40 -1
  15. package/docs/agent/zh-CN/README.md +1 -1
  16. package/docs/agent/zh-CN/coding-agent-integration.md +6 -2
  17. package/docs/agent/zh-CN/diagram-scenario.md +32 -3
  18. package/docs/agent/zh-CN/editing-contract.md +2 -0
  19. package/docs/agent/zh-CN/frontend-integration.md +1 -1
  20. package/docs/agent/zh-CN/iteration-12.md +98 -0
  21. package/docs/agent/zh-CN/runtime-contract.md +3 -1
  22. package/docs/agent/zh-CN/theme-guide.md +63 -0
  23. package/docs/agent/zh-CN/usage-scenarios.md +40 -1
  24. package/docs/manifests/capabilities.json +37 -2
  25. package/docs/manifests/commands.json +10 -9
  26. package/docs/manifests/schemas.json +5 -2
  27. package/package.json +1 -1
  28. package/skills/ichartjs/SKILL.md +11 -2
  29. package/src/capabilities.mjs +65 -8
  30. package/src/charts.mjs +127 -44
  31. package/src/command.mjs +4 -3
  32. package/src/diagram-interaction.mjs +55 -11
  33. package/src/diagram.mjs +149 -36
  34. package/src/edit-controller.mjs +6 -3
  35. package/src/edit.mjs +17 -7
  36. package/src/index.mjs +68 -17
  37. package/src/layout.mjs +39 -0
  38. package/src/preferences-ui.mjs +135 -0
  39. package/src/preferences.mjs +194 -0
  40. package/src/project.mjs +64 -24
  41. package/src/renderer.mjs +2 -2
  42. package/src/scene.mjs +32 -1
  43. package/src/schema.mjs +7 -2
  44. package/src/spec.mjs +10 -4
  45. package/src/theme.mjs +1 -1
  46. package/types/index.d.ts +39 -3
@@ -3,12 +3,13 @@
3
3
  */
4
4
  import { inspectData } from './data.mjs';
5
5
  import { planStyle, styleCapabilities } from './theme.mjs';
6
+ import { defaultPreferences, preferenceDensities, preferenceMotions, preferenceTriStates } from './preferences.mjs';
6
7
 
7
- export const chartTypes = ['line', 'area', 'bar', 'column', 'pie', 'scatter', 'funnel', 'gauge', 'heatmap', 'radar', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane'];
8
+ export const chartTypes = ['line', 'area', 'bar', 'column', 'pie', 'scatter', 'funnel', 'gauge', 'heatmap', 'radar', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'];
8
9
 
9
10
  const cartesian = ['line', 'area', 'bar', 'column', 'scatter'];
10
11
  const project = ['gantt', 'timeline', 'milestone', 'burndown'];
11
- const diagrams = ['flow', 'swimlane'];
12
+ const diagrams = ['flow', 'swimlane', 'architecture', 'mindmap'];
12
13
  const status = (supported, notApplicable = []) => Object.fromEntries(supported.map(name => [name, 'supported']).concat(notApplicable.map(name => [name, 'not-applicable'])));
13
14
  const commonPresentation = ['title', 'subtitle', 'theme', 'responsive', 'empty-state', 'invalid-data-state', 'export', 'branding'];
14
15
 
@@ -27,8 +28,10 @@ const definitions = {
27
28
  timeline: { family: 'project', intents: ['timeline', 'milestone'], required: ['date', 'label'], optional: ['status'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting']) },
28
29
  milestone: { family: 'project', intents: ['milestone'], required: ['date', 'label'], optional: ['baseline', 'actual'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting', 'baseline-actual-variance']) },
29
30
  burndown: { family: 'project', intents: ['progress', 'release'], required: ['date', 'remaining'], optional: ['ideal', 'scopeChange'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'labels', 'formatting', 'scope-change', 'release-forecast']) },
30
- flow: { family: 'diagram', intents: ['workflow'], required: ['nodes'], optional: ['edges', 'groups', 'ports'], interactions: ['hover', 'selection', 'drag', 'keyboard-edit', 'keyboard-port-connect', 'port-connect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'layout', 'groups', 'ports', 'history'], ['axes', 'grid', 'legend']) },
31
- swimlane: { family: 'diagram', intents: ['responsibility'], required: ['nodes', 'lanes'], optional: ['edges', 'groups', 'ports'], interactions: ['hover', 'selection', 'drag', 'keyboard-edit', 'keyboard-port-connect', 'port-connect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'layout', 'groups', 'ports', 'lanes', 'history'], ['axes', 'grid', 'legend']) }
31
+ flow: { family: 'diagram', intents: ['workflow'], required: ['nodes'], optional: ['edges', 'groups', 'ports'], interactions: ['hover', 'tooltip', 'selection', 'edge-selection', 'drag', 'edgeDrag', 'zoom', 'pan', 'keyboard', 'keyboard-edit', 'keyboard-port-connect', 'portConnect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'manual-routing', 'layout', 'groups', 'ports', 'history'], ['axes', 'grid', 'legend']) },
32
+ swimlane: { family: 'diagram', intents: ['responsibility'], required: ['nodes', 'lanes'], optional: ['edges', 'groups', 'ports'], interactions: ['hover', 'tooltip', 'selection', 'edge-selection', 'drag', 'edgeDrag', 'zoom', 'pan', 'keyboard', 'keyboard-edit', 'keyboard-port-connect', 'portConnect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'manual-routing', 'layout', 'groups', 'ports', 'lanes', 'history'], ['axes', 'grid', 'legend']) },
33
+ architecture: { family: 'diagram', intents: ['architecture', 'business-architecture', 'data-architecture', 'technical-architecture'], required: ['nodes'], optional: ['edges', 'layers', 'boundaries', 'groups', 'ports'], interactions: ['hover', 'tooltip', 'selection', 'edge-selection', 'drag', 'edgeDrag', 'zoom', 'pan', 'keyboard', 'keyboard-edit', 'keyboard-port-connect', 'portConnect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'manual-routing', 'layout', 'layers', 'boundaries', 'groups', 'ports', 'history'], ['axes', 'grid', 'legend']) },
34
+ mindmap: { family: 'diagram', intents: ['mindmap', 'hierarchy', 'brainstorm'], required: ['nodes'], optional: ['parentId', 'edges', 'groups'], interactions: ['hover', 'tooltip', 'selection', 'edge-selection', 'drag', 'edgeDrag', 'zoom', 'pan', 'keyboard', 'keyboard-edit', 'keyboard-port-connect', 'portConnect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'cubic-bezier', 'curve-tension', 'obstacle-fallback', 'manual-routing', 'layout', 'tree-layout', 'radial-layout', 'history'], ['axes', 'grid', 'legend']) }
32
35
  };
33
36
 
34
37
  export const chartProfiles = Object.fromEntries(chartTypes.map(type => [type, {
@@ -41,11 +44,11 @@ export const chartProfiles = Object.fromEntries(chartTypes.map(type => [type, {
41
44
  }]));
42
45
 
43
46
  const intentMap = {
44
- schedule: 'gantt', variance: 'gantt', timeline: 'timeline', milestone: 'milestone', progress: 'burndown', release: 'burndown', capacity: 'column', risk: 'scatter', aging: 'bar', matrix: 'heatmap', 'correlation-grid': 'heatmap', multidimensional: 'radar', profile: 'radar', workflow: 'flow', responsibility: 'swimlane', trend: 'line', 'time-series': 'line', 'part-to-whole': 'pie', distribution: 'column', relationship: 'scatter', correlation: 'scatter', funnel: 'funnel', conversion: 'funnel', 'single-value': 'gauge', ranking: 'bar', comparison: 'bar', composition: 'column'
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'
45
48
  };
46
49
 
47
50
  const alternatives = {
48
- heatmap: ['scatter', 'column'], radar: ['bar', 'line'], gantt: ['timeline', 'milestone'], timeline: ['milestone', 'gantt'], milestone: ['timeline', 'gantt'], burndown: ['line', 'gantt'], column: ['bar', 'line'], scatter: ['bar', 'line'], bar: ['column', 'line'], flow: ['swimlane', 'gantt'], swimlane: ['flow', 'gantt'], line: ['area', 'column'], pie: ['bar', 'column'], funnel: ['bar', 'column'], gauge: ['column', 'bar']
51
+ 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']
49
52
  };
50
53
 
51
54
  function warning(code, path, message, suggestion) { return { code, path, message, ...(suggestion ? { suggestion } : {}) }; }
@@ -54,6 +57,49 @@ export function getChartCapability(type) {
54
57
  return chartProfiles[type] ? JSON.parse(JSON.stringify(chartProfiles[type])) : null;
55
58
  }
56
59
 
60
+ const preferenceMessages = {
61
+ en: {
62
+ groups: { theme: 'Theme', typography: 'Typography', components: 'Components', behavior: 'Behavior' },
63
+ fields: { mode: 'Theme', preset: 'Style preset', palette: 'Palette', scale: 'Font size', density: 'Density', legend: 'Legend', labels: 'Data labels', grid: 'Grid lines', branding: 'Branding', motion: 'Motion' },
64
+ descriptions: { mode: 'Select automatic, light, dark, or high-contrast rendering.', preset: 'Select a semantic visual preset.', palette: 'Select a semantic color palette.', scale: 'Scale chart typography without changing business data.', density: 'Adjust chart spacing and padding density.', legend: 'Show, hide, or automatically resolve the legend.', labels: 'Show, hide, or automatically resolve data labels.', grid: 'Show, hide, or automatically resolve grid lines.', branding: 'Control the Powered by iChart.js signature.', motion: 'Control animation and motion preference.' },
65
+ options: { auto: 'Auto', light: 'Light', dark: 'Dark', contrast: 'High contrast', analysis: 'Analysis', dashboard: 'Dashboard', report: 'Report', presentation: 'Presentation', project: 'Project', diagram: 'Diagram', categorical: 'Categorical', sequential: 'Sequential', diverging: 'Diverging', status: 'Status', compact: 'Compact', comfortable: 'Comfortable', spacious: 'Spacious', full: 'Full', reduced: 'Reduced', off: 'Off', true: 'Show', false: 'Hide', '0.85': 'Small', '1': 'Default', '1.15': 'Large', '1.3': 'Extra large' }
66
+ },
67
+ 'zh-CN': {
68
+ groups: { theme: '主题', typography: '排版', components: '显示内容', behavior: '行为' },
69
+ fields: { mode: '主题', preset: '样式预设', palette: '配色', scale: '字号', density: '布局密度', legend: '图例', labels: '数据标签', grid: '网格线', branding: '品牌署名', motion: '动效' },
70
+ descriptions: { mode: '选择自动、浅色、深色或高对比度主题。', preset: '选择语义化视觉预设。', palette: '选择语义化配色方案。', scale: '在不改变业务数据的情况下缩放图表字体。', density: '调整图表间距与内边距密度。', legend: '显示、隐藏或自动判断图例。', labels: '显示、隐藏或自动判断数据标签。', grid: '显示、隐藏或自动判断网格线。', branding: '控制 Powered by iChart.js 品牌署名。', motion: '控制动画和动效偏好。' },
71
+ options: { auto: '自动', light: '浅色', dark: '深色', contrast: '高对比度', analysis: '分析', dashboard: '仪表盘', report: '报告', presentation: '演示', project: '项目', diagram: '图表结构', categorical: '分类', sequential: '渐变', diverging: '发散', status: '状态', compact: '紧凑', comfortable: '舒适', spacious: '宽松', full: '完整', reduced: '减少', off: '关闭', true: '显示', false: '隐藏', '0.85': '小', '1': '默认', '1.15': '大', '1.3': '特大' }
72
+ }
73
+ };
74
+
75
+ function preferenceLocale(locale) {
76
+ const normalized = String(locale || 'en').toLowerCase();
77
+ return normalized === 'zh' || normalized.startsWith('zh-') ? 'zh-CN' : 'en';
78
+ }
79
+
80
+ export function getPreferenceCapabilities(chartType = null, { locale = 'en' } = {}) {
81
+ const resolvedLocale = preferenceLocale(locale), text = preferenceMessages[resolvedLocale], profile = chartType ? chartProfiles[chartType] || null : null;
82
+ const option = value => ({ value, label: text.options[String(value ?? 'auto')] || String(value ?? 'auto') });
83
+ const feature = name => chartType == null ? { supported: true, applicability: 'chart-dependent' } : { supported: profile?.features?.[name] === 'supported', applicability: profile?.features?.[name] || 'not-applicable' };
84
+ const scopes = ['global', 'chart'];
85
+ const field = ({ path, group, key, type, values, defaultValue, menu = false, control = 'select', support = { supported: true, applicability: 'all-charts' }, minimum, maximum }) => ({
86
+ path, group, groupLabel: text.groups[group], type, label: text.fields[key], description: text.descriptions[key], default: defaultValue, options: values.map(option), scopes: [...scopes], supported: support.supported, applicability: support.applicability, menu: { visible: menu && support.supported, control }, ...(minimum == null ? {} : { minimum }), ...(maximum == null ? {} : { maximum })
87
+ });
88
+ const fields = [
89
+ field({ path: 'theme.mode', group: 'theme', key: 'mode', type: 'enum', values: [null, ...styleCapabilities.modes.filter(value => value !== 'auto')], defaultValue: defaultPreferences.theme.mode, menu: true }),
90
+ field({ path: 'theme.preset', group: 'theme', key: 'preset', type: 'enum', values: [null, ...styleCapabilities.presets.filter(value => value !== 'auto')], defaultValue: defaultPreferences.theme.preset }),
91
+ field({ path: 'theme.palette', group: 'theme', key: 'palette', type: 'enum', values: [null, ...styleCapabilities.palettes.filter(value => value !== 'auto')], defaultValue: defaultPreferences.theme.palette, menu: true }),
92
+ field({ path: 'typography.scale', group: 'typography', key: 'scale', type: 'number', values: [0.85, 1, 1.15, 1.3], defaultValue: defaultPreferences.typography.scale, menu: true, minimum: 0.75, maximum: 1.5 }),
93
+ field({ path: 'density', group: 'typography', key: 'density', type: 'enum', values: preferenceDensities, defaultValue: defaultPreferences.density }),
94
+ field({ path: 'components.legend', group: 'components', key: 'legend', type: 'tri-state', values: preferenceTriStates, defaultValue: defaultPreferences.components.legend, menu: true, control: 'toggle', support: feature('legend') }),
95
+ field({ path: 'components.labels', group: 'components', key: 'labels', type: 'tri-state', values: preferenceTriStates, defaultValue: defaultPreferences.components.labels, menu: true, control: 'toggle', support: feature('labels') }),
96
+ field({ path: 'components.grid', group: 'components', key: 'grid', type: 'tri-state', values: preferenceTriStates, defaultValue: defaultPreferences.components.grid, menu: true, control: 'toggle', support: feature('grid') }),
97
+ field({ path: 'branding.enabled', group: 'components', key: 'branding', type: 'tri-state', values: preferenceTriStates, defaultValue: defaultPreferences.branding.enabled }),
98
+ field({ path: 'motion', group: 'behavior', key: 'motion', type: 'enum', values: preferenceMotions, defaultValue: defaultPreferences.motion })
99
+ ];
100
+ return { version: '1.0', locale: resolvedLocale, chartType: profile?.type || null, scopes, persistence: ['memory', 'localStorage', 'adapter'], precedence: ['defaults', 'global', 'chart', 'temporary-agent-patch'], fields };
101
+ }
102
+
57
103
  export function planChart(input, options = {}) {
58
104
  const report = inspectData(input);
59
105
  const requestedIntent = options.intent || 'comparison';
@@ -132,6 +178,7 @@ export function explainChart(spec, model = {}) {
132
178
 
133
179
  export function getCapabilities() {
134
180
  const intents = [...new Set(Object.values(chartProfiles).flatMap(profile => profile.intents))];
181
+ const preferenceCapabilities = getPreferenceCapabilities();
135
182
  const exp = {
136
183
  types: ['png', 'jpeg', 'svg', 'json'],
137
184
  mime: { png: 'image/png', jpeg: 'image/jpeg', svg: 'image/svg+xml', json: 'application/json' },
@@ -161,10 +208,10 @@ export function getCapabilities() {
161
208
  chartTypes,
162
209
  charts: JSON.parse(JSON.stringify(chartProfiles)),
163
210
  intents,
164
- chartModes: { stack: ['stacked', 'percent'], pie: ['standard', 'donut'], composition: ['multi-series', 'mixed-line-column', 'dual-axis'], transforms: ['bin'] },
211
+ 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'] },
165
212
  projectManagement: project,
166
213
  projectIntelligence: {
167
- intents: ['schedule', 'milestone', 'progress', 'variance', 'capacity', 'release', 'risk', 'aging', 'workflow', 'responsibility'],
214
+ intents: ['schedule', 'milestone', 'progress', 'variance', 'capacity', 'release', 'risk', 'aging', 'workflow', 'responsibility', 'architecture', 'mindmap'],
168
215
  views: ['gantt', 'burndown', 'column', 'area', 'scatter', 'bar'],
169
216
  analytics: ['calendar', 'dependency-normalization', 'critical-path', 'slack', 'baseline-actual-variance', 'capacity', 'cumulative-flow', 'velocity', 'release-forecast', 'risk-matrix', 'issue-aging'],
170
217
  linkedState: ['owner', 'status', 'priority', 'sprint', 'label'],
@@ -184,6 +231,16 @@ export function getCapabilities() {
184
231
  data: ['normalize', 'inspect', 'filter', 'sort', 'groupBy', 'sum', 'average', 'topN', 'percentage', 'bin'],
185
232
  themes: [...styleCapabilities.modes],
186
233
  styleSystem: JSON.parse(JSON.stringify(styleCapabilities)),
234
+ preferences: {
235
+ version: '1.0',
236
+ scopes: preferenceCapabilities.scopes,
237
+ persistence: preferenceCapabilities.persistence,
238
+ fields: preferenceCapabilities.fields.map(field => field.path),
239
+ agentAdjustable: true,
240
+ interactiveSettingsUI: true,
241
+ precedence: preferenceCapabilities.precedence,
242
+ schema: preferenceCapabilities
243
+ },
187
244
  plugins: true,
188
245
  branding: { defaultEnabled: true, signature: 'Powered by iChart.js', options: [{ name: 'enabled', type: 'boolean', default: true }] },
189
246
  };
package/src/charts.mjs CHANGED
@@ -8,27 +8,66 @@ import { applyTransforms } from './transforms.mjs';
8
8
  import { buildProjectScene } from './project.mjs';
9
9
  import { formatValue } from './format.mjs';
10
10
  import { resolveTheme } from './theme.mjs';
11
+ import { axisLabelLayout, boxesOverlap, estimateTextWidth, fontSize, titleLayout } from './layout.mjs';
11
12
 
12
13
  const pick = (row, encoding, fallback) => row?.[encoding?.field || fallback];
13
14
  const font = (spec, role, fallback = '12px system-ui') => spec.theme?.typography?.[role]?.font || fallback;
14
15
 
16
+ function chromeLayout(spec, entries = []) {
17
+ const title = titleLayout(spec);
18
+ const visible = spec.legend?.visible !== false && entries.length >= 1;
19
+ if (!visible) return { title, legend: { visible: false, items: [], bottom: title.bottom }, bottom: title.bottom };
20
+ const size = fontSize(spec, 'legend', 12), lineHeight = Math.max(18, size * Number(spec.theme?.typography?.legend?.lineHeight || 1.4));
21
+ const swatchSize = Math.max(8, Math.round(size * 0.75)), gap = Math.max(12, Math.round(size));
22
+ const availableWidth = Math.max(1, spec.width - spec.padding.left - spec.padding.right);
23
+ const measured = entries.map((entry, index) => ({ ...entry, index, width: Math.min(availableWidth, swatchSize + 6 + String(entry.name).length * size * 0.58 + gap) }));
24
+ const rows = [];
25
+ measured.forEach(item => {
26
+ let row = rows.at(-1);
27
+ if (!row || row.width + item.width > availableWidth) { row = { width: 0, items: [] }; rows.push(row); }
28
+ row.items.push(item);
29
+ row.width += item.width;
30
+ });
31
+ const top = title.bottom ? title.bottom + 8 : Math.max(8, (spec.padding.top - rows.length * lineHeight) / 2);
32
+ const items = [];
33
+ rows.forEach((row, rowIndex) => {
34
+ let x = Math.max(spec.padding.left, spec.width - spec.padding.right - row.width);
35
+ const y = top + rowIndex * lineHeight + lineHeight / 2;
36
+ row.items.forEach(item => { items.push({ ...item, x, y, swatchSize }); x += item.width; });
37
+ });
38
+ const bottom = top + rows.length * lineHeight;
39
+ return { title, legend: { visible: true, items, bottom }, bottom: Math.max(title.bottom, bottom) };
40
+ }
41
+
15
42
  function styledSpec(spec) {
16
43
  const theme = spec.theme && typeof spec.theme === 'object' && spec.theme.typography ? spec.theme : resolveTheme(spec.theme, spec);
17
44
  return { ...spec, theme, colors: spec.colors || theme.colors, background: spec.background || theme.background, padding: spec.padding || theme.layout.padding };
18
45
  }
19
46
 
20
- function layout(spec, data) {
47
+ function layout(spec, data, legendEntries = []) {
21
48
  const p = spec.padding, width = spec.width, height = spec.height;
22
- const categoryField = spec.encoding.x?.field || 'name';
23
- const categoryLabelLength = spec.type === 'bar' ? Math.max(0, ...data.rows.map(row => String(row[categoryField] ?? '').length)) : 0;
24
- const plotLeft = spec.type === 'bar' ? Math.min(width * 0.35, Math.max(p.left, categoryLabelLength * 8 + 24)) : p.left;
25
- const plotTop = Math.max(p.top, spec.title?.subtitle ? 72 : spec.title?.text ? 56 : p.top);
26
- const plot = { x: plotLeft, y: plotTop, width: Math.max(1, width - plotLeft - p.right), height: Math.max(1, height - plotTop - p.bottom) };
49
+ const chrome = chromeLayout(spec, legendEntries);
27
50
  const xEncoding = spec.encoding.x || { field: 'name', type: 'category' };
28
51
  const xField = xEncoding.field || 'name';
52
+ const categories = [...new Set(data.rows.map(row => String(row[xField] ?? '')))].filter(Boolean);
53
+ const temporal = xEncoding.type === 'temporal' || xEncoding.type === 'time';
54
+ const quantitativeX = xEncoding.type === 'quantitative' || xEncoding.type === 'linear';
55
+ const dates = temporal ? data.rows.map(row => new Date(row[xField]).getTime()).filter(Number.isFinite) : [];
56
+ const xNumbers = quantitativeX ? data.rows.map(row => Number(row[xField])).filter(Number.isFinite) : [];
57
+ const xMin = dates.length ? Math.min(...dates) : xNumbers.length ? Math.min(...xNumbers) : 0, xMax = dates.length ? Math.max(...dates) : xNumbers.length ? Math.max(...xNumbers) : 1;
58
+ const categoryField = spec.encoding.x?.field || 'name';
59
+ const categoryLabelLength = spec.type === 'bar' ? Math.max(0, ...data.rows.map(row => String(row[categoryField] ?? '').length)) : 0;
60
+ const matrixYField = spec.encoding.y?.field || 'y', matrixLabelLength = spec.type === 'heatmap' ? Math.max(0, ...data.rows.map(row => String(row[matrixYField] ?? '').length)) : 0;
61
+ const sideLabelLength = Math.max(categoryLabelLength, matrixLabelLength);
62
+ 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
+ const plotTop = Math.max(p.top, chrome.bottom + (chrome.bottom ? 12 : 0));
64
+ 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;
66
+ const xLabels = axisLabelLayout(spec, spec.type === 'bar' ? [] : axisLabelTexts, plotWidth);
67
+ const bottomReserve = Math.max(p.bottom, Math.ceil(16 + xLabels.projectedHeight + (spec.xAxis?.title ? 20 : 0)));
68
+ const plot = { x: plotLeft, y: plotTop, width: plotWidth, height: Math.max(1, height - plotTop - bottomReserve) };
29
69
  const yEncodings = Array.isArray(spec.encoding.y) ? spec.encoding.y : [spec.encoding.y || { field: 'value', type: 'quantitative' }];
30
70
  const yField = yEncodings[0]?.field || 'value';
31
- const categories = [...new Set(data.rows.map(row => String(row[xField] ?? '')))].filter(Boolean);
32
71
  const stackMode = typeof spec.stack === 'string' ? spec.stack : spec.stack?.mode;
33
72
  const stackTotals = stackMode ? data.rows.flatMap(row => {
34
73
  const values = yEncodings.map(encoding => Number(row[encoding.field])).filter(Number.isFinite);
@@ -38,11 +77,6 @@ function layout(spec, data) {
38
77
  const numbers = (stackTotals.length ? stackTotals : data.rows.flatMap(row => yEncodings.map(encoding => Number(row[encoding.field])))).filter(Number.isFinite);
39
78
  const min = Math.min(0, ...(numbers.length ? numbers : [0]));
40
79
  const max = Math.max(1, ...(numbers.length ? numbers : [1]));
41
- const temporal = xEncoding.type === 'temporal' || xEncoding.type === 'time';
42
- const quantitativeX = xEncoding.type === 'quantitative' || xEncoding.type === 'linear';
43
- const dates = temporal ? data.rows.map(row => new Date(row[xField]).getTime()).filter(Number.isFinite) : [];
44
- const xNumbers = quantitativeX ? data.rows.map(row => Number(row[xField])).filter(Number.isFinite) : [];
45
- const xMin = dates.length ? Math.min(...dates) : xNumbers.length ? Math.min(...xNumbers) : 0, xMax = dates.length ? Math.max(...dates) : xNumbers.length ? Math.max(...xNumbers) : 1;
46
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);
47
81
  const axisType = spec.yAxis?.type || yEncodings[0]?.type || 'linear';
48
82
  const transform = value => axisType === 'log' ? Math.log10(Math.max(0.000001, Number(value))) : Number(value);
@@ -53,7 +87,7 @@ function layout(spec, data) {
53
87
  const rightType = spec.yAxis?.right?.type || yEncodings[1]?.type || 'linear';
54
88
  const rightTransform = value => rightType === 'log' ? Math.log10(Math.max(0.000001, Number(value))) : Number(value);
55
89
  const yRight = value => plot.y + plot.height - ((rightTransform(value) - rightTransform(rightMin)) / (rightTransform(rightMax) - rightTransform(rightMin) || 1)) * plot.height;
56
- return { plot, xField, xEncoding, temporal, quantitativeX, xMin, xMax, yField, yEncodings, categories, min, max, rightMin, rightMax, x, y, yRight, axisType, rightType };
90
+ return { plot, chrome, xLabels, compact: width < 360 || height < 240, recommendedSize: { minWidth: 280, minHeight: 220 }, xField, xEncoding, temporal, quantitativeX, xMin, xMax, yField, yEncodings, categories, min, max, rightMin, rightMax, x, y, yRight, axisType, rightType };
57
91
  }
58
92
 
59
93
  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 })); }
@@ -72,27 +106,50 @@ function addAxes(scene, spec, state) {
72
106
  const labels = state.temporal || state.quantitativeX ? timeTicks(state.xMin, state.xMax, 5) : categories;
73
107
  const yTicks = [min, min + (max - min) / 2, max];
74
108
  if (spec.grid?.visible !== false) yTicks.forEach((value, index) => scene.add(new SceneNode({ id: `grid-y-${index}`, type: 'line', geometry: { x1: plot.x, y1: y(value), x2: plot.x + plot.width, y2: y(value) }, style: { stroke: spec.grid?.color || spec.theme.grid, strokeWidth: spec.theme.marks.gridWidth }, zIndex: -2 })));
75
- labels.forEach(category => addText(scene, `label-x-${category}`, state.temporal ? formatTime(category, state.xMax - state.xMin) : state.quantitativeX ? formatValue(category, spec.xAxis?.format) : category, x(category), plot.y + plot.height + 22, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'middle' }));
109
+ labels.forEach((category, index) => {
110
+ 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 });
112
+ });
76
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' }));
77
114
  if (state.yEncodings.length > 1 && !spec.stack) [state.rightMin, (state.rightMin + state.rightMax) / 2, state.rightMax].forEach(value => addText(scene, `label-y-right-${value}`, formatValue(value, spec.yAxis?.right?.format), plot.x + plot.width + 10, yRight(value) + 4, { fill: spec.theme.muted, font: font(spec, 'axis') }));
78
- if (spec.xAxis?.title) addText(scene, 'axis-x-title', spec.xAxis.title, plot.x + plot.width / 2, plot.y + plot.height + 42, { fill: spec.theme.text, font: font(spec, 'axis'), textAnchor: 'middle' });
115
+ 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' });
79
116
  if (spec.yAxis?.title) addText(scene, 'axis-y-title', spec.yAxis.title, 8, plot.y + 12, { fill: spec.theme.text, font: font(spec, 'axis') });
80
117
  }
81
118
 
82
- function addTitles(scene, spec) {
83
- if (spec.title?.text) addText(scene, 'title', spec.title.text, spec.width / 2, 22, { fill: spec.theme.text, font: font(spec, 'title'), textAnchor: 'middle' });
84
- if (spec.title?.subtitle) addText(scene, 'subtitle', spec.title.subtitle, spec.width / 2, 40, { fill: spec.theme.muted, font: font(spec, 'subtitle'), textAnchor: 'middle' });
119
+ function addTitles(scene, spec, positions) {
120
+ if (spec.title?.text) addText(scene, 'title', spec.title.text, spec.width / 2, positions.titleY, { fill: spec.theme.text, font: font(spec, 'title'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' });
121
+ if (spec.title?.subtitle) addText(scene, 'subtitle', spec.title.subtitle, spec.width / 2, positions.subtitleY, { fill: spec.theme.muted, font: font(spec, 'subtitle'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' });
122
+ }
123
+
124
+ function addLegend(scene, spec, legend) {
125
+ if (!legend.visible) return;
126
+ legend.items.forEach(item => { scene.add(new SceneNode({ id: `legend-swatch-${item.index}`, type: 'rect', geometry: { x: item.x, y: item.y - item.swatchSize / 2, width: item.swatchSize, height: item.swatchSize }, style: { fill: item.color } })); addText(scene, `legend-label-${item.index}`, item.name, item.x + item.swatchSize + 6, item.y, { fill: spec.theme.muted, font: font(spec, 'legend'), textBaseline: 'middle', baseline: 'middle' }); });
85
127
  }
86
128
 
87
- function addLegend(scene, spec, entries) {
88
- if (spec.legend?.visible === false || entries.length < 2) return;
89
- const startX = Math.max(12, spec.width - spec.padding.right - entries.length * 92), y = spec.title?.subtitle ? 52 : 38;
90
- entries.forEach((entry, index) => { const x = startX + index * 92; scene.add(new SceneNode({ id: `legend-swatch-${index}`, type: 'rect', geometry: { x, y: y - 8, width: 10, height: 10 }, style: { fill: entry.color } })); addText(scene, `legend-label-${index}`, entry.name, x + 15, y, { fill: spec.theme.muted, font: font(spec, 'legend') }); });
129
+ function legendEntries(spec, data, series, colors) {
130
+ if (!data.rows.length || spec.legend?.visible === false) return [];
131
+ if (spec.type === 'radar') return data.rows.map((row, index) => ({ name: row.name || row.id || `Series ${index + 1}`, color: colors[index % colors.length] }));
132
+ if (['line', 'area', 'bar', 'column'].includes(spec.type)) return series.map((encoding, index) => ({ name: encoding.name || encoding.field, color: colors[index % colors.length] }));
133
+ if (spec.type === 'pie') { const categoryField = spec.encoding.category?.field || 'name'; return data.rows.map((row, index) => ({ name: String(row[categoryField]), color: colors[index % colors.length] })); }
134
+ return [];
91
135
  }
92
136
 
93
- function addMarkLabel(scene, spec, id, value, x, y, dataRef) {
137
+ function addMarkLabel(scene, spec, id, value, x, y, dataRef, bounds = null, anchor = null) {
94
138
  if (!spec.labels?.enabled) return;
95
- addText(scene, `${id}-label`, formatValue(value, spec.labels.format), x, y, { fill: spec.labels.color || spec.theme.text, font: spec.labels.font || font(spec, 'label'), textAnchor: 'middle' }, dataRef);
139
+ const text = formatValue(value, spec.labels.format), size = fontSize(spec, 'label', 12), width = estimateTextWidth(text, size), height = size * 1.25;
140
+ const area = bounds || scene._labelArea || { x: 0, y: 0, width: spec.width, height: spec.height };
141
+ const safeX = Math.max(area.x + width / 2, Math.min(area.x + area.width - width / 2, x));
142
+ const safeY = Math.max(area.y + height / 2, Math.min(area.y + area.height - height / 2, y));
143
+ const anchorRadius = Number(anchor?.radius) || 0, anchorGap = Number(anchor?.gap) || 6;
144
+ const anchorBox = anchor ? { x: anchor.x - anchorRadius - anchorGap, y: anchor.y - anchorRadius - anchorGap, width: anchorRadius * 2 + anchorGap * 2, height: anchorRadius * 2 + anchorGap * 2 } : null;
145
+ const preferred = anchor ? anchor.y - anchorRadius - anchorGap - height / 2 : safeY;
146
+ const alternate = anchor ? anchor.y + anchorRadius + anchorGap + height / 2 : safeY;
147
+ const shifts = anchor ? [preferred, alternate, preferred - height - 3, alternate + height + 3] : [safeY, safeY - height - 3, safeY + height + 3, safeY - 2 * (height + 3), safeY + 2 * (height + 3)];
148
+ const occupied = scene._labelBoxes || (scene._labelBoxes = []);
149
+ const placement = shifts.map(candidateY => Math.max(area.y + height / 2, Math.min(area.y + area.height - height / 2, candidateY))).map(candidateY => ({ x: safeX, y: candidateY, box: { x: safeX - width / 2, y: candidateY - height / 2, width, height } })).find(candidate => !occupied.some(box => boxesOverlap(candidate.box, box, 2)) && (!anchorBox || !boxesOverlap(candidate.box, anchorBox, 0)));
150
+ if (!placement) return;
151
+ occupied.push(placement.box);
152
+ addText(scene, `${id}-label`, text, placement.x, placement.y, { fill: spec.labels.color || spec.theme.text, font: spec.labels.font || font(spec, 'label'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }, dataRef);
96
153
  }
97
154
 
98
155
  function hexRgb(value) { const hex = String(value || '').replace('#', ''); if (!/^[0-9a-f]{6}$/i.test(hex)) return [37, 99, 235]; return [0, 2, 4].map(index => parseInt(hex.slice(index, index + 2), 16)); }
@@ -106,8 +163,9 @@ function addHeatmapScene(scene, spec, data, state, colors) {
106
163
  const palette = spec.theme.palette === 'diverging' ? spec.theme.palettes.diverging : spec.theme.palettes.sequential;
107
164
  const low = spec.colorScale?.range?.[0] || palette[0], high = spec.colorScale?.range?.at(-1) || palette.at(-1) || colors[0], missing = spec.colorScale?.missing || spec.theme.palettes.missing;
108
165
  const gap = Math.max(0, Number(spec.cellPadding ?? 2)), width = state.plot.width / Math.max(1, xValues.length), height = state.plot.height / Math.max(1, yValues.length);
109
- xValues.forEach((value, index) => addText(scene, `heatmap-x-${index}`, value, state.plot.x + (index + .5) * width, state.plot.y + state.plot.height + 20, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'middle' }));
110
- yValues.forEach((value, index) => addText(scene, `heatmap-y-${index}`, value, state.plot.x - 10, state.plot.y + (index + .5) * height + 4, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'end' }));
166
+ 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
+ const yStep = Math.max(1, Math.ceil(fontSize(spec, 'axis', 12) * 1.35 / Math.max(1, height)));
168
+ 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' }); });
111
169
  data.rows.forEach((row, index) => { const xIndex = xValues.indexOf(String(row[xField])), yIndex = yValues.indexOf(String(row[yField])), value = numericValue(row), geometry = { x: state.plot.x + xIndex * width + gap / 2, y: state.plot.y + yIndex * height + gap / 2, width: Math.max(1, width - gap), height: Math.max(1, height - gap) }; scene.add(new SceneNode({ id: `heatmap-cell-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: Number.isFinite(value) ? colorMix(low, high, (value - min) / (max - min || 1)) : missing, stroke: spec.theme.background, strokeWidth: 1 }, dataRef: { dataIndex: index, x: row[xField], y: row[yField], value: Number.isFinite(value) ? value : null, recordId: row.id || `record-${index}` }, interactive: true })); });
112
170
  state.matrix = { xValues, yValues, min, max };
113
171
  }
@@ -117,9 +175,34 @@ function addRadarScene(scene, spec, data, state, colors) {
117
175
  if (indicators.some(indicator => !Number.isFinite(Number(indicator.min)) || !Number.isFinite(Number(indicator.max)))) data.warnings.push({ code: 'AMBIGUOUS_RADAR_DOMAIN', message: 'Declare finite min and max for every radar indicator, especially for mixed units.' });
118
176
  const point = (indicator, index, ratio = 1) => { const angle = -Math.PI / 2 + index * Math.PI * 2 / indicators.length; return { x: cx + Math.cos(angle) * radius * ratio, y: cy + Math.sin(angle) * radius * ratio }; };
119
177
  [0.25, 0.5, 0.75, 1].forEach((ratio, level) => scene.add(new SceneNode({ id: `radar-grid-${level}`, type: 'path', geometry: { points: indicators.map((indicator, index) => point(indicator, index, ratio)), closed: true }, style: { fill: 'none', stroke: spec.theme.grid, strokeWidth: spec.theme.marks.gridWidth } })));
120
- indicators.forEach((indicator, index) => { const edge = point(indicator, index); scene.add(new SceneNode({ id: `radar-axis-${index}`, type: 'line', geometry: { x1: cx, y1: cy, x2: edge.x, y2: edge.y }, style: { stroke: spec.theme.axis } })); const label = point(indicator, index, 1.14); addText(scene, `radar-label-${index}`, indicator.name || indicator.field, label.x, label.y + 4, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'middle' }); });
121
- data.rows.forEach((row, seriesIndex) => { const points = indicators.map((indicator, index) => { const min = Number(indicator.min ?? 0), max = Number(indicator.max ?? 1), raw = row[indicator.field], value = raw == null || raw === '' ? NaN : Number(raw); if (!Number.isFinite(value)) data.warnings.push({ code: 'INVALID_RADAR_VALUE', path: `rows.${seriesIndex}.${indicator.field}`, message: 'Radar indicator value must be numeric.' }); return point(indicator, index, Number.isFinite(value) ? Math.max(0, Math.min(1, (value - min) / (max - min || 1))) : 0); }); scene.add(new SceneNode({ id: `radar-series-${seriesIndex}`, type: 'path', geometry: { points, closed: true }, style: { fill: `${colors[seriesIndex % colors.length]}33`, stroke: colors[seriesIndex % colors.length], strokeWidth: 2 }, dataRef: { dataIndex: seriesIndex, recordId: row.id || `record-${seriesIndex}` } })); points.forEach((position, indicatorIndex) => { const dataRef = { dataIndex: seriesIndex, indicatorIndex, field: indicators[indicatorIndex].field, recordId: row.id || `record-${seriesIndex}` }; scene.add(new SceneNode({ id: `radar-item-${seriesIndex}-${indicatorIndex}`, type: 'circle', geometry: { cx: position.x, cy: position.y, r: 4 }, bounds: { x: position.x - 7, y: position.y - 7, width: 14, height: 14 }, style: { fill: colors[seriesIndex % colors.length] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `radar-item-${seriesIndex}-${indicatorIndex}`, row[indicators[indicatorIndex].field], position.x, position.y - 7, dataRef); }); });
178
+ const labelSize = fontSize(spec, 'axis', 12), occupied = [], labelArea = { x: 4, y: Math.max(4, state.chrome.bottom + 4), width: spec.width - 8, height: Math.max(1, spec.height - spec.padding.bottom - Math.max(4, state.chrome.bottom + 4)) };
179
+ let hiddenLabels = 0;
180
+ indicators.forEach((indicator, index) => {
181
+ const edge = point(indicator, index), angle = -Math.PI / 2 + index * Math.PI * 2 / indicators.length;
182
+ scene.add(new SceneNode({ id: `radar-axis-${index}`, type: 'line', geometry: { x1: cx, y1: cy, x2: edge.x, y2: edge.y }, style: { stroke: spec.theme.axis } }));
183
+ const rawText = String(indicator.name || indicator.field), maxChars = Math.max(5, Math.floor(Math.max(48, state.plot.width * 0.28) / (labelSize * 0.58))), labelText = rawText.length > maxChars ? `${rawText.slice(0, Math.max(1, maxChars - 1))}…` : rawText;
184
+ const width = estimateTextWidth(labelText, labelSize), height = labelSize * 1.3, anchor = Math.cos(angle) > .2 ? 'start' : Math.cos(angle) < -.2 ? 'end' : 'middle';
185
+ const candidates = [];
186
+ [1.14, 1.26, 1.38, 1.5].forEach(ratio => {
187
+ const base = point(indicator, index, ratio);
188
+ [0, -height, height, -2 * height, 2 * height].forEach(offset => candidates.push({ x: base.x, y: base.y + offset }));
189
+ });
190
+ const placement = candidates.map(candidate => {
191
+ let x = candidate.x, y = candidate.y;
192
+ let box = { x: anchor === 'start' ? x : anchor === 'end' ? x - width : x - width / 2, y: y - height / 2, width, height };
193
+ const dx = box.x < labelArea.x ? labelArea.x - box.x : box.x + box.width > labelArea.x + labelArea.width ? labelArea.x + labelArea.width - box.x - box.width : 0;
194
+ const dy = box.y < labelArea.y ? labelArea.y - box.y : box.y + box.height > labelArea.y + labelArea.height ? labelArea.y + labelArea.height - box.y - box.height : 0;
195
+ x += dx; y += dy; box = { ...box, x: box.x + dx, y: box.y + dy };
196
+ return { x, y, box };
197
+ }).find(candidate => !occupied.some(box => boxesOverlap(candidate.box, box, 3)));
198
+ if (!placement) { hiddenLabels += 1; return; }
199
+ occupied.push(placement.box);
200
+ addText(scene, `radar-label-${index}`, labelText, placement.x, placement.y, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: anchor, textBaseline: 'middle', baseline: 'middle' });
201
+ });
202
+ data.rows.forEach((row, seriesIndex) => { const points = indicators.map((indicator, index) => { const min = Number(indicator.min ?? 0), max = Number(indicator.max ?? 1), raw = row[indicator.field], value = raw == null || raw === '' ? NaN : Number(raw); if (!Number.isFinite(value)) data.warnings.push({ code: 'INVALID_RADAR_VALUE', path: `rows.${seriesIndex}.${indicator.field}`, message: 'Radar indicator value must be numeric.' }); return point(indicator, index, Number.isFinite(value) ? Math.max(0, Math.min(1, (value - min) / (max - min || 1))) : 0); }); scene.add(new SceneNode({ id: `radar-series-${seriesIndex}`, type: 'path', geometry: { points, closed: true }, style: { fill: `${colors[seriesIndex % colors.length]}33`, stroke: colors[seriesIndex % colors.length], strokeWidth: 2 }, dataRef: { dataIndex: seriesIndex, recordId: row.id || `record-${seriesIndex}` } })); points.forEach((position, indicatorIndex) => { const dataRef = { dataIndex: seriesIndex, indicatorIndex, field: indicators[indicatorIndex].field, recordId: row.id || `record-${seriesIndex}` }; scene.add(new SceneNode({ id: `radar-item-${seriesIndex}-${indicatorIndex}`, type: 'circle', geometry: { cx: position.x, cy: position.y, r: 4 }, bounds: { x: position.x - 7, y: position.y - 7, width: 14, height: 14 }, style: { fill: colors[seriesIndex % colors.length] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `radar-item-${seriesIndex}-${indicatorIndex}`, row[indicators[indicatorIndex].field], position.x, position.y - 7, dataRef, state.plot, { x: position.x, y: position.y, radius: 4 }); }); });
122
203
  state.indicators = indicators.map(indicator => ({ ...indicator }));
204
+ state.labelLayout = { ...(state.labelLayout || {}), radar: { hidden: hiddenLabels, visible: indicators.length - hiddenLabels } };
205
+ if (hiddenLabels) data.warnings.push({ code: 'LABELS_SUPPRESSED', path: 'indicators', message: `${hiddenLabels} radar labels were suppressed because the available chart area cannot place them without overlap.`, suggestion: 'Increase chart width or height, shorten indicator names, or reduce the indicator count.' });
123
206
  }
124
207
 
125
208
  function timeTicks(min, max, count) { return Array.from({ length: count }, (_, index) => min + (max - min) * index / Math.max(1, count - 1)); }
@@ -183,7 +266,7 @@ export function buildScene(spec) {
183
266
  left: Number(baseSpec.padding?.left ?? 56),
184
267
  };
185
268
  spec = { ...baseSpec, padding: effectivePadding };
186
- const isProjectFamily = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane'].includes(spec.type);
269
+ const isProjectFamily = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type);
187
270
  if (isProjectFamily) {
188
271
  const result = buildProjectScene(spec);
189
272
  addBranding(result.scene, spec);
@@ -196,39 +279,39 @@ export function buildScene(spec) {
196
279
  data.viewOffset = start;
197
280
  }
198
281
  const scene = new Scene(spec.width, spec.height);
199
- const state = layout(spec, data);
200
282
  const colors = spec.colors;
201
- addTitles(scene, spec);
283
+ const series = Array.isArray(spec.encoding.y) ? spec.encoding.y : [{ ...spec.encoding.y, name: spec.encoding.y?.name || spec.encoding.y?.field }];
284
+ const entries = legendEntries(spec, data, series, colors);
285
+ const state = layout(spec, data, entries);
286
+ scene._labelArea = state.plot;
287
+ addTitles(scene, spec, state.chrome.title);
202
288
  if (!['pie', 'funnel', 'gauge', 'heatmap', 'radar'].includes(spec.type)) addAxes(scene, spec, state);
203
- const hasDiagramNodes = ['flow', 'swimlane'].includes(spec.type) && (spec.nodes || spec.data.nodes)?.length;
289
+ const hasDiagramNodes = ['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type) && (spec.nodes || spec.data.nodes)?.length;
204
290
  if (!data.rows.length && !hasDiagramNodes) { addText(scene, 'empty', spec.emptyText || 'No data', spec.width / 2, spec.height / 2, { fill: spec.theme.muted, font: font(spec, 'subtitle'), textAnchor: 'middle' }); addBranding(scene, spec); return { scene, data, state }; }
205
291
  const yField = state.yField;
206
- const series = Array.isArray(spec.encoding.y) ? spec.encoding.y : [{ ...spec.encoding.y, name: spec.encoding.y?.name || spec.encoding.y?.field }];
207
292
  if (spec.type === 'heatmap') addHeatmapScene(scene, spec, data, state, colors);
208
- else if (spec.type === 'radar') { addRadarScene(scene, spec, data, state, colors); addLegend(scene, spec, data.rows.map((row, index) => ({ name: row.name || row.id || `Series ${index + 1}`, color: colors[index % colors.length] }))); }
293
+ else if (spec.type === 'radar') addRadarScene(scene, spec, data, state, colors);
209
294
  else if (spec.type === 'line' || spec.type === 'area') {
210
295
  const mode = typeof spec.stack === 'string' ? spec.stack : spec.stack?.mode, accumulators = data.rows.map(() => ({ positive: 0, negative: 0 }));
211
- series.forEach((encoding, seriesIndex) => { const yMap = seriesIndex === 1 && !mode ? state.yRight : state.y, values = data.rows.map(row => Number(row[encoding.field])), totals = data.rows.map((row, index) => series.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || Math.abs(values[index]) || 1); const points = [], bases = []; data.rows.forEach((row, index) => { let value = values[index]; if (!Number.isFinite(value)) return; if (mode === 'percent') value /= totals[index]; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = mode ? start + value : value; if (mode) accumulators[index][accumulator] = end; points.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(end), row, index, value }); bases.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(start) }); }); if (spec.type === 'area' && points.length) scene.add(new SceneNode({ id: `area-fill-${seriesIndex}`, type: 'path', geometry: { points: [...bases.slice().reverse(), ...points.map(point => ({ x: point.x, y: point.y }))], closed: true }, style: { fill: `${colors[seriesIndex]}33`, stroke: 'transparent' } })); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points, closed: false }, style: { stroke: colors[seriesIndex], strokeWidth: 2, fill: 'none' } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 5 }, bounds: { x: point.x - 8, y: point.y - 8, width: 16, height: 16 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.y - 8, dataRef); }); });
212
- addLegend(scene, spec, series.map((encoding, index) => ({ name: encoding.name || encoding.field, color: colors[index % colors.length] })));
296
+ series.forEach((encoding, seriesIndex) => { const yMap = seriesIndex === 1 && !mode ? state.yRight : state.y, values = data.rows.map(row => Number(row[encoding.field])), totals = data.rows.map((row, index) => series.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || Math.abs(values[index]) || 1); const points = [], bases = []; data.rows.forEach((row, index) => { let value = values[index]; if (!Number.isFinite(value)) return; if (mode === 'percent') value /= totals[index]; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = mode ? start + value : value; if (mode) accumulators[index][accumulator] = end; points.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(end), row, index, value }); bases.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(start) }); }); if (spec.type === 'area' && points.length) scene.add(new SceneNode({ id: `area-fill-${seriesIndex}`, type: 'path', geometry: { points: [...bases.slice().reverse(), ...points.map(point => ({ x: point.x, y: point.y }))], closed: true }, style: { fill: `${colors[seriesIndex]}33`, stroke: 'transparent' } })); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points, closed: false }, style: { stroke: colors[seriesIndex], strokeWidth: 2, fill: 'none' } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 5 }, bounds: { x: point.x - 8, y: point.y - 8, width: 16, height: 16 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.y - 8, dataRef, state.plot, { x: point.x, y: point.y, radius: 5 }); }); });
213
297
  } else if (spec.type === 'bar' || spec.type === 'column') {
214
298
  const band = state.plot.width / Math.max(1, state.categories.length), bar = band * 0.58;
215
299
  const mode = typeof spec.stack === 'string' ? spec.stack : spec.stack?.mode, accumulators = data.rows.map(() => ({ positive: 0, negative: 0 }));
216
- series.forEach((encoding, seriesIndex) => { if (encoding.mark === 'line') { const yMap = encoding.axis === 'right' ? state.yRight : state.y, points = data.rows.map((row, index) => ({ x: state.x(row[state.xField]), y: yMap(row[encoding.field]), index, value: Number(row[encoding.field]) })).filter(point => Number.isFinite(point.y)); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points, closed: false }, style: { fill: 'none', stroke: colors[seriesIndex], strokeWidth: 2 } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 7, y: point.y - 7, width: 14, height: 14 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.y - 7, dataRef); }); return; } data.rows.forEach((row, index) => { let value = Number(row[encoding.field]) || 0; const barSeries = series.filter(item => item.mark !== 'line'), barSeriesIndex = barSeries.indexOf(encoding), total = barSeries.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || 1; if (mode === 'percent') value /= total; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = start + value; if (mode) accumulators[index][accumulator] = end; const yMap = encoding.axis === 'right' ? state.yRight : state.y, base = yMap(start), valueY = yMap(end); const vertical = spec.type === 'column'; const horizontalStart = state.plot.x + ((start - state.min) / (state.max - state.min || 1)) * state.plot.width, horizontalEnd = state.plot.x + ((end - state.min) / (state.max - state.min || 1)) * state.plot.width, rowBand = state.plot.height / Math.max(1, data.rows.length), totalHeight = rowBand * .62, itemHeight = mode ? totalHeight : totalHeight / Math.max(1, barSeries.length); const geometry = vertical ? { x: state.x(row[state.xField]) - bar / 2 + (mode ? 0 : seriesIndex * bar / Math.max(1, series.length)), y: Math.min(base, valueY), width: mode ? bar : bar / Math.max(1, series.length), height: Math.abs(base - valueY) } : { x: Math.min(horizontalStart, horizontalEnd), y: state.plot.y + index * rowBand + (rowBand - totalHeight) / 2 + (mode ? 0 : barSeriesIndex * itemHeight), width: Math.abs(horizontalEnd - horizontalStart), height: itemHeight }; const dataRef = { seriesIndex, dataIndex: index, field: encoding.field, stackStart: start, stackEnd: end }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${index}`, value, vertical ? geometry.x + geometry.width / 2 : geometry.x + geometry.width + 12, vertical ? geometry.y - 5 : geometry.y + geometry.height / 2 + 4, dataRef); }); });
217
- addLegend(scene, spec, series.map((encoding, index) => ({ name: encoding.name || encoding.field, color: colors[index % colors.length] })));
300
+ series.forEach((encoding, seriesIndex) => { if (encoding.mark === 'line') { const yMap = encoding.axis === 'right' ? state.yRight : state.y, points = data.rows.map((row, index) => ({ x: state.x(row[state.xField]), y: yMap(row[encoding.field]), index, value: Number(row[encoding.field]) })).filter(point => Number.isFinite(point.y)); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points, closed: false }, style: { fill: 'none', stroke: colors[seriesIndex], strokeWidth: 2 } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 7, y: point.y - 7, width: 14, height: 14 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.y - 7, dataRef, null, { x: point.x, y: point.y, radius: 4 }); }); return; } data.rows.forEach((row, index) => { let value = Number(row[encoding.field]) || 0; const barSeries = series.filter(item => item.mark !== 'line'), barSeriesIndex = barSeries.indexOf(encoding), total = barSeries.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || 1; if (mode === 'percent') value /= total; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = start + value; if (mode) accumulators[index][accumulator] = end; const yMap = encoding.axis === 'right' ? state.yRight : state.y, base = yMap(start), valueY = yMap(end); const vertical = spec.type === 'column'; const horizontalStart = state.plot.x + ((start - state.min) / (state.max - state.min || 1)) * state.plot.width, horizontalEnd = state.plot.x + ((end - state.min) / (state.max - state.min || 1)) * state.plot.width, rowBand = state.plot.height / Math.max(1, data.rows.length), totalHeight = rowBand * .62, itemHeight = mode ? totalHeight : totalHeight / Math.max(1, barSeries.length); const geometry = vertical ? { x: state.x(row[state.xField]) - bar / 2 + (mode ? 0 : seriesIndex * bar / Math.max(1, series.length)), y: Math.min(base, valueY), width: mode ? bar : bar / Math.max(1, series.length), height: Math.abs(base - valueY) } : { x: Math.min(horizontalStart, horizontalEnd), y: state.plot.y + index * rowBand + (rowBand - totalHeight) / 2 + (mode ? 0 : barSeriesIndex * itemHeight), width: Math.abs(horizontalEnd - horizontalStart), height: itemHeight }; const dataRef = { seriesIndex, dataIndex: index, field: encoding.field, stackStart: start, stackEnd: end }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${index}`, value, vertical ? geometry.x + geometry.width / 2 : geometry.x + geometry.width + 12, vertical ? geometry.y - 5 : geometry.y + geometry.height / 2 + 4, dataRef); }); });
218
301
  } else if (spec.type === 'scatter') {
219
- data.rows.forEach((row, index) => { const xValue = Number(row[state.xField]), yValue = Number(row[state.yField]); if (!Number.isFinite(xValue) || !Number.isFinite(yValue)) return; const geometry = { cx: state.x(xValue), cy: state.y(yValue), r: 5 }, dataRef = { seriesIndex: 0, dataIndex: index, field: state.yField }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'circle', geometry, bounds: { x: geometry.cx - 8, y: geometry.cy - 8, width: 16, height: 16 }, style: { fill: colors[0] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, yValue, geometry.cx, geometry.cy - 8, dataRef); });
302
+ 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 }); });
220
303
  } else if (spec.type === 'pie') {
221
- const categoryField = spec.encoding.category?.field || 'name', valueField = spec.encoding.value?.field || 'value', total = data.rows.reduce((sum, row) => sum + Math.max(0, Number(row[valueField]) || 0), 0), cx = spec.width / 2, cy = spec.height / 2 + 12, radius = Math.min(spec.width, spec.height) * 0.28, innerR = radius * Number(spec.innerRadius || 0); let angle = -Math.PI / 2;
304
+ const categoryField = spec.encoding.category?.field || 'name', valueField = spec.encoding.value?.field || 'value', total = data.rows.reduce((sum, row) => sum + Math.max(0, Number(row[valueField]) || 0), 0), cx = 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;
222
305
  if (!(total > 0)) { data.warnings.push({ code: 'ZERO_TOTAL', path: `encoding.value.${valueField}`, message: 'Pie requires a positive total.' }); addText(scene, 'pie-zero-total', spec.emptyText || 'No positive values', cx, cy, { fill: spec.theme.muted, font: font(spec, 'subtitle'), textAnchor: 'middle' }); }
223
- else data.rows.forEach((row, index) => { const value = Math.max(0, Number(row[valueField]) || 0), end = angle + value / total * Math.PI * 2, middle = angle + (end - angle) / 2, dataRef = { seriesIndex: 0, dataIndex: index, category: row[categoryField], value }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'arc', geometry: { cx, cy, r: radius, innerR, start: angle, end }, bounds: { x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2 }, style: { fill: colors[index % colors.length], stroke: spec.theme.background, strokeWidth: 1 }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value / total, cx + Math.cos(middle) * radius * .72, cy + Math.sin(middle) * radius * .72, dataRef); angle = end; });
224
- addLegend(scene, spec, data.rows.map((row, index) => ({ name: String(row[categoryField]), color: colors[index % colors.length] })));
306
+ else data.rows.forEach((row, index) => { const value = Math.max(0, Number(row[valueField]) || 0), end = angle + value / total * Math.PI * 2, middle = angle + (end - angle) / 2, dataRef = { seriesIndex: 0, dataIndex: index, category: row[categoryField], value }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'arc', geometry: { cx, cy, r: radius, innerR, start: angle, end }, bounds: { x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2 }, style: { fill: colors[index % colors.length], stroke: spec.theme.background, strokeWidth: 1 }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value / total, cx + Math.cos(middle) * radius * .72, cy + Math.sin(middle) * radius * .72, dataRef, state.plot); angle = end; });
225
307
  } else if (spec.type === 'funnel') {
226
308
  const valueField = spec.encoding.value?.field || 'value'; const maxValue = Math.max(...data.rows.map(row => Number(row[valueField]) || 0), 1); const segmentHeight = state.plot.height / data.rows.length;
227
- data.rows.forEach((row, index) => { const value = Number(row[valueField]) || 0, ratio = Math.max(0.1, value / maxValue); const width = state.plot.width * ratio; const geometry = { x: state.plot.x + (state.plot.width - width) / 2, y: state.plot.y + index * segmentHeight, width, height: Math.max(2, segmentHeight - 3) }, dataRef = { seriesIndex: 0, dataIndex: index, field: valueField }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: colors[index % colors.length] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value, spec.width / 2, geometry.y + geometry.height / 2 + 4, dataRef); });
309
+ 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); });
228
310
  } else if (spec.type === 'gauge') {
229
- const valueField = spec.encoding.value?.field || 'value', domain = spec.domain || [0, 100], rawValue = Number(data.rows[0]?.[valueField]) || 0, value = Math.max(Number(domain[0]), Math.min(Number(domain[1]), rawValue)), ratio = (value - Number(domain[0])) / (Number(domain[1]) - Number(domain[0]) || 1), cx = spec.width / 2, cy = spec.height * 0.62, radius = Math.min(spec.width, spec.height) * 0.3, start = Math.PI, end = start + Math.PI * ratio;
311
+ const valueField = spec.encoding.value?.field || 'value', domain = spec.domain || [0, 100], rawValue = Number(data.rows[0]?.[valueField]) || 0, value = Math.max(Number(domain[0]), Math.min(Number(domain[1]), rawValue)), ratio = (value - Number(domain[0])) / (Number(domain[1]) - Number(domain[0]) || 1), cx = 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;
230
312
  scene.add(new SceneNode({ id: 'gauge-background', type: 'arc', geometry: { cx, cy, r: radius, start: Math.PI, end: Math.PI * 2 }, style: { fill: spec.theme.grid } })); scene.add(new SceneNode({ id: 'gauge-value', type: 'arc', geometry: { cx, cy, r: radius, start, end }, style: { fill: colors[0] }, dataRef: { seriesIndex: 0, dataIndex: 0, value }, interactive: true })); addText(scene, 'gauge-label', formatValue(value, spec.labels?.format || spec.encoding.value?.format || { style: 'percent', ratio: false }), cx, cy - 12, { fill: spec.theme.text, font: font(spec, 'metric'), textAnchor: 'middle' });
231
313
  }
314
+ addLegend(scene, spec, state.chrome.legend);
232
315
  addBranding(scene, spec);
233
316
  return { scene, data, state };
234
317
  }
package/src/command.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import { copyJSON, issue } from './schema.mjs';
6
6
 
7
7
  export const commandVersion = '1.0';
8
- export const operationTypes = ['updateField', 'updateRecord', 'updateTask', 'shiftTask', 'updateProgress', 'addDependency', 'removeDependency', 'updateMilestone', 'moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'toggleGroupCollapse', 'addEdge', 'duplicateSelection', 'pasteSelection'];
8
+ export const operationTypes = ['updateField', 'updateRecord', 'updateTask', 'shiftTask', 'updateProgress', 'addDependency', 'removeDependency', 'updateMilestone', 'moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'removeEdge', 'toggleGroupCollapse', 'addEdge', 'duplicateSelection', 'pasteSelection'];
9
9
 
10
10
  export function normalizeCommand(input) {
11
11
  const command = copyJSON(input);
@@ -62,8 +62,8 @@ export function validateCommand(command) {
62
62
  export function commandCapabilities(schema) {
63
63
  const fields = schema?.fields || {};
64
64
  const can = name => fields[name]?.editable !== false && fields[name]?.agentEditable !== false;
65
- const flowNode = schema?.name === 'flow-node';
66
- const flowEdge = schema?.name === 'flow-edge';
65
+ const flowNode = ['flow-node', 'architecture-node', 'mindmap-node'].includes(schema?.name);
66
+ const flowEdge = ['flow-edge', 'architecture-edge'].includes(schema?.name);
67
67
  return {
68
68
  updateField: Object.keys(fields).filter(can),
69
69
  updateRecord: true,
@@ -88,6 +88,7 @@ export function commandCapabilities(schema) {
88
88
  duplicateSelection: flowNode,
89
89
  pasteSelection: flowNode,
90
90
  updateEdge: flowEdge,
91
+ removeEdge: flowEdge,
91
92
  addEdge: flowEdge
92
93
  };
93
94
  }