@taylorwong/ichartjs 2.0.6 → 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.
- package/CHANGELOG.md +7 -0
- package/README.md +2 -2
- package/docs/agent/README.md +3 -1
- package/docs/agent/coding-agent-integration.md +1 -1
- package/docs/agent/development/iteration-12.md +101 -0
- package/docs/agent/development/roadmap.md +2 -1
- package/docs/agent/development-guide.md +1 -1
- package/docs/agent/diagram-scenario.md +39 -11
- package/docs/agent/editing-contract.md +5 -0
- package/docs/agent/frontend-integration.md +1 -1
- package/docs/agent/runtime-contract.md +11 -1
- package/docs/agent/theme-guide.md +33 -1
- package/docs/agent/usage-scenarios.md +3 -1
- package/docs/agent/zh-CN/README.md +1 -1
- package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
- package/docs/agent/zh-CN/diagram-scenario.md +32 -3
- package/docs/agent/zh-CN/editing-contract.md +2 -0
- package/docs/agent/zh-CN/frontend-integration.md +1 -1
- package/docs/agent/zh-CN/iteration-12.md +98 -0
- package/docs/agent/zh-CN/runtime-contract.md +3 -1
- package/docs/agent/zh-CN/theme-guide.md +33 -1
- package/docs/agent/zh-CN/usage-scenarios.md +3 -1
- package/docs/manifests/capabilities.json +30 -3
- package/docs/manifests/commands.json +10 -9
- package/docs/manifests/schemas.json +5 -2
- package/package.json +1 -1
- package/skills/ichartjs/SKILL.md +2 -1
- package/src/capabilities.mjs +60 -12
- package/src/charts.mjs +127 -44
- package/src/command.mjs +4 -3
- package/src/diagram-interaction.mjs +55 -11
- package/src/diagram.mjs +149 -36
- package/src/edit-controller.mjs +6 -3
- package/src/edit.mjs +17 -7
- package/src/index.mjs +34 -15
- package/src/layout.mjs +39 -0
- package/src/preferences-ui.mjs +73 -24
- package/src/project.mjs +64 -24
- package/src/renderer.mjs +2 -2
- package/src/scene.mjs +32 -1
- package/src/schema.mjs +7 -2
- package/src/spec.mjs +10 -4
- package/src/theme.mjs +1 -1
- package/types/index.d.ts +22 -4
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
|
|
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(
|
|
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,
|
|
84
|
-
if (spec.title?.subtitle) addText(scene, 'subtitle', spec.title.subtitle, spec.width / 2,
|
|
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
|
|
88
|
-
if (spec.legend?.visible === false
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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')
|
|
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 =
|
|
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 =
|
|
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 =
|
|
66
|
-
const flowEdge =
|
|
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
|
}
|
|
@@ -4,18 +4,24 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { buildScene } from './charts.mjs';
|
|
6
6
|
|
|
7
|
-
export const isDiagram = chart => ['flow', 'swimlane'].includes(chart.spec.type);
|
|
7
|
+
export const isDiagram = chart => ['flow', 'swimlane', 'architecture', 'mindmap'].includes(chart.spec.type);
|
|
8
8
|
|
|
9
9
|
function focusableNodes(chart) {
|
|
10
10
|
const items = [];
|
|
11
11
|
chart.model.scene.walk(node => {
|
|
12
12
|
if (!node.interactive) return;
|
|
13
|
-
if (!/^node-|^group-|^port-/.test(node.id)) return;
|
|
13
|
+
if (!/^node-|^group-|^port-|^edge-/.test(node.id) || node.dataRef?.edgeHandle) return;
|
|
14
14
|
items.push(node);
|
|
15
15
|
});
|
|
16
16
|
return items;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function edgeNode(chart, edgeId) {
|
|
20
|
+
let found = null;
|
|
21
|
+
chart.model.scene.walk(node => { if (!found && node.dataRef?.edgeId === edgeId && !node.dataRef?.edgeHandle) found = node; });
|
|
22
|
+
return found;
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
function center(node) {
|
|
20
26
|
if (node.geometry?.cx != null && node.geometry?.cy != null) return { x: node.geometry.cx, y: node.geometry.cy };
|
|
21
27
|
return { x: node.bounds?.x + node.bounds?.width / 2 || node.geometry?.x || 0, y: node.bounds?.y + node.bounds?.height / 2 || node.geometry?.y || 0 };
|
|
@@ -32,15 +38,31 @@ function paintConnectPreview(chart, start, end) {
|
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export function paintSelection(chart) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
const selected = new Map();
|
|
42
|
+
for (const [id, reference] of chart._selected) {
|
|
43
|
+
const node = reference?.edgeId ? edgeNode(chart, reference.edgeId) : chart.model.scene.find(id);
|
|
44
|
+
if (node) { node.selected = true; selected.set(node.id, node.dataRef); }
|
|
39
45
|
}
|
|
46
|
+
chart._selected = selected;
|
|
47
|
+
const selectedEdges = [];
|
|
40
48
|
chart.model.scene.walk(node => {
|
|
41
49
|
if (node.id === chart._diagramFocusTarget) node.highlighted = true;
|
|
42
50
|
if (node.selected && node.id.startsWith('node-')) { node.style.stroke = '#f59e0b'; node.style.strokeWidth = 3; }
|
|
43
51
|
if (node.selected && node.id.startsWith('group-')) { node.style.stroke = '#2563eb'; node.style.strokeWidth = 2; }
|
|
52
|
+
if (node.selected && node.dataRef?.edgeId) { node.style.stroke = chart.spec.theme?.selection || '#2563eb'; node.style.strokeWidth = Math.max(3, Number(node.style.strokeWidth) || 1); selectedEdges.push(node); }
|
|
53
|
+
});
|
|
54
|
+
if (chart.spec.editing?.enabled !== true || chart.spec.interaction?.edgeDrag !== true) return;
|
|
55
|
+
selectedEdges.forEach(edge => {
|
|
56
|
+
if (edge.geometry?.curve === 'cubic') return;
|
|
57
|
+
const points = edge.geometry?.points || [], edgeId = edge.dataRef.edgeId;
|
|
58
|
+
points.slice(1, -1).forEach((point, offset) => chart.model.scene.add({ id: `edge-handle-waypoint-${edge.id}-${offset + 1}`, 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: chart.spec.theme?.background || '#fff', stroke: chart.spec.theme?.selection || '#2563eb', strokeWidth: 2 }, dataRef: { edgeId, edgeHandle: 'waypoint', pointIndex: offset + 1, routePoints: points.map(value => ({ x: value.x, y: value.y })) }, interactive: true, zIndex: 102 }));
|
|
59
|
+
points.forEach((point, index) => {
|
|
60
|
+
if (index === 0 || index >= points.length - 2) return;
|
|
61
|
+
const next = points[index + 1], orthogonal = point.x === next.x || point.y === next.y;
|
|
62
|
+
if (!orthogonal) return;
|
|
63
|
+
const middle = { x: (point.x + next.x) / 2, y: (point.y + next.y) / 2 };
|
|
64
|
+
chart.model.scene.add({ id: `edge-handle-segment-${edge.id}-${index}`, type: 'rect', geometry: { x: middle.x - 5, y: middle.y - 5, width: 10, height: 10 }, bounds: { x: middle.x - 8, y: middle.y - 8, width: 16, height: 16 }, style: { fill: chart.spec.theme?.selection || '#2563eb', stroke: chart.spec.theme?.background || '#fff', strokeWidth: 1 }, dataRef: { edgeId, edgeHandle: 'segment', segmentIndex: index, routePoints: points.map(value => ({ x: value.x, y: value.y })) }, interactive: true, zIndex: 103 });
|
|
65
|
+
});
|
|
44
66
|
});
|
|
45
67
|
}
|
|
46
68
|
|
|
@@ -55,20 +77,21 @@ export function diagramPointer(chart, phase, event, target) {
|
|
|
55
77
|
target.focus?.({ preventScroll: true });
|
|
56
78
|
target.setPointerCapture?.(event.pointerId);
|
|
57
79
|
const hit = chart.model.scene.hit(point.x, point.y), additive = Boolean(event.shiftKey || event.ctrlKey || event.metaKey);
|
|
58
|
-
const nodeId = hit?.dataRef?.nodeId, groupId = hit?.dataRef?.groupId, portId = hit?.dataRef?.portId;
|
|
59
|
-
if (
|
|
80
|
+
const nodeId = hit?.dataRef?.nodeId, groupId = hit?.dataRef?.groupId, portId = hit?.dataRef?.portId, edgeId = hit?.dataRef?.edgeId, edgeHandle = hit?.dataRef?.edgeHandle;
|
|
81
|
+
if (edgeId) chart.selectEdges([edgeId], { additive: edgeHandle ? true : additive });
|
|
82
|
+
else if (groupId && !nodeId) chart.selectGroup(groupId, { additive });
|
|
60
83
|
else if (nodeId) {
|
|
61
84
|
if (additive && chart.getSelectedNodeIds().includes(nodeId)) chart.selectNodes(chart.getSelectedNodeIds().filter(id => id !== nodeId));
|
|
62
85
|
else if (additive || !chart.getSelectedNodeIds().includes(nodeId)) chart.selectNodes([nodeId], { additive });
|
|
63
86
|
} else if (!additive) chart.clearSelection();
|
|
64
|
-
const mode = portId && chart.spec.editing?.enabled ? 'connect' : nodeId && chart.spec.interaction?.drag && chart.spec.editing?.enabled ? 'nodes' : !hit && (additive || chart.spec.interaction?.brush) ? 'box' : !hit && chart.spec.interaction?.pan ? 'pan' : groupId ? 'group' : 'select';
|
|
87
|
+
const mode = edgeHandle && chart.spec.editing?.enabled && chart.spec.interaction?.edgeDrag ? 'edge-handle' : portId && chart.spec.editing?.enabled && chart.spec.interaction?.portConnect ? 'connect' : nodeId && chart.spec.interaction?.drag && chart.spec.editing?.enabled ? 'nodes' : !hit && (additive || chart.spec.interaction?.brush) ? 'box' : !hit && chart.spec.interaction?.pan ? 'pan' : groupId ? 'group' : 'select';
|
|
65
88
|
const view = chart.model.state.view, positions = {};
|
|
66
89
|
chart.getSelectedNodeIds().forEach(id => {
|
|
67
90
|
const geometry = chart.model.scene.find(`node-${id}`)?.geometry;
|
|
68
91
|
if (!geometry) return;
|
|
69
92
|
positions[id] = { x: (geometry.x - view.offsetX) / view.scale, y: (geometry.y - view.offsetY) / view.scale };
|
|
70
93
|
});
|
|
71
|
-
chart._diagramGesture = { mode, point, previous: point, positions, view: { ...view }, pointerId: event.pointerId, revision: chart._revision, additive, selected: chart.getSelectedNodeIds(), moved: false, hitId: hit?.id, groupId, nodeId, portId };
|
|
94
|
+
chart._diagramGesture = { mode, point, previous: point, positions, view: { ...view }, pointerId: event.pointerId, revision: chart._revision, additive, selected: chart.getSelectedNodeIds(), moved: false, hitId: hit?.id, groupId, nodeId, portId, edgeId, edgeHandle, pointIndex: hit?.dataRef?.pointIndex, segmentIndex: hit?.dataRef?.segmentIndex, routePoints: hit?.dataRef?.routePoints?.map(value => ({ ...value })) };
|
|
72
95
|
if (mode === 'connect' && hit) paintConnectPreview(chart, center(hit), point);
|
|
73
96
|
return true;
|
|
74
97
|
}
|
|
@@ -84,6 +107,20 @@ export function diagramPointer(chart, phase, event, target) {
|
|
|
84
107
|
paintConnectPreview(chart, center(source), point);
|
|
85
108
|
chart.emit('connectionpreview', { chart, source: source.dataRef, coordinate: point });
|
|
86
109
|
}
|
|
110
|
+
} else if (gesture.mode === 'edge-handle') {
|
|
111
|
+
const grid = chart.spec.diagram?.grid ?? 8, snapped = chart.spec.diagram?.snap === false || event.altKey ? point : { x: Math.round(point.x / grid) * grid, y: Math.round(point.y / grid) * grid }, routePoints = gesture.routePoints.map(value => ({ ...value }));
|
|
112
|
+
if (gesture.edgeHandle === 'waypoint') routePoints[gesture.pointIndex] = snapped;
|
|
113
|
+
else {
|
|
114
|
+
const first = routePoints[gesture.segmentIndex], second = routePoints[gesture.segmentIndex + 1];
|
|
115
|
+
if (first.x === second.x) first.x = second.x = snapped.x;
|
|
116
|
+
else first.y = second.y = snapped.y;
|
|
117
|
+
}
|
|
118
|
+
gesture.waypoints = routePoints.slice(1, -1);
|
|
119
|
+
const edges = chart.getDiagramEdges().map((edge, index) => (edge.id || `edge-${index}`) === gesture.edgeId ? { ...edge, waypoints: gesture.waypoints } : edge);
|
|
120
|
+
chart.model = buildScene({ ...chart.spec, edges });
|
|
121
|
+
paintSelection(chart);
|
|
122
|
+
if (chart.renderer.container) chart.renderer.render(chart.model.scene);
|
|
123
|
+
chart.emit('drag', { chart, edgeId: gesture.edgeId, waypoints: gesture.waypoints, coordinate: point });
|
|
87
124
|
} else if (gesture.mode === 'nodes') {
|
|
88
125
|
let delta = { x: (point.x - gesture.point.x) / gesture.view.scale, y: (point.y - gesture.point.y) / gesture.view.scale };
|
|
89
126
|
const anchor = Object.values(gesture.positions)[0], grid = chart.spec.diagram?.grid ?? 8;
|
|
@@ -113,6 +150,10 @@ export function diagramPointer(chart, phase, event, target) {
|
|
|
113
150
|
const preview = chart.previewEdit({ type: 'layout-edit', reason: 'Drag diagram selection', operations: gesture.operations });
|
|
114
151
|
if (preview.requiresConfirmation) chart.emit('editrequest', { chart, preview, source: 'pointer' });
|
|
115
152
|
else chart.applyEdit(preview.command, { preview, source: 'pointer', confirmed: true });
|
|
153
|
+
} else if (!cancelled && gesture.moved && gesture.mode === 'edge-handle' && gesture.waypoints) {
|
|
154
|
+
const preview = chart.previewEdit({ type: 'layout-edit', reason: 'Drag diagram edge handle', operations: [{ op: 'updateEdge', edgeId: gesture.edgeId, changes: { waypoints: gesture.waypoints } }] });
|
|
155
|
+
if (preview.requiresConfirmation) chart.emit('editrequest', { chart, preview, source: 'pointer' });
|
|
156
|
+
else chart.applyEdit(preview.command, { preview, source: 'pointer', confirmed: true });
|
|
116
157
|
} else if (!cancelled && gesture.moved && gesture.mode === 'box') {
|
|
117
158
|
chart.selectBox(gesture.point, point);
|
|
118
159
|
if (gesture.additive) chart.selectNodes(gesture.selected, { additive: true });
|
|
@@ -145,6 +186,7 @@ export function diagramKeyboard(chart, event) {
|
|
|
145
186
|
if (modifier && key === 'v') { chart.pasteSelection({ confirmed: true, source: 'keyboard' }); event.preventDefault(); return true; }
|
|
146
187
|
if (modifier && key === 'd') { chart.duplicateSelection({ confirmed: true, source: 'keyboard' }); event.preventDefault(); return true; }
|
|
147
188
|
if (modifier && ['z', 'y'].includes(key) && chart.spec.editing?.enabled) { chart[key === 'y' || event.shiftKey ? 'redo' : 'undo'](); event.preventDefault(); return true; }
|
|
189
|
+
if (['delete', 'backspace'].includes(key) && chart.spec.editing?.enabled && chart.spec.editing?.allowDelete && chart.getSelectedEdgeIds().length) { chart.deleteSelectedEdges({ confirmed: true, source: 'keyboard' }); event.preventDefault(); return true; }
|
|
148
190
|
if (['tab', 'enter', ' '].includes(key)) {
|
|
149
191
|
const step = event.shiftKey ? -1 : 1;
|
|
150
192
|
if (!nodes.length) return true;
|
|
@@ -154,13 +196,14 @@ export function diagramKeyboard(chart, event) {
|
|
|
154
196
|
if (target?.id?.startsWith('node-')) chart.selectNodes([target.dataRef.nodeId], { additive: key === ' ' && event.shiftKey });
|
|
155
197
|
else if (target?.id?.startsWith('group-')) chart.selectGroup(target.dataRef.groupId, { additive: key === ' ' && event.shiftKey });
|
|
156
198
|
else if (target?.id?.startsWith('port-') && target.dataRef?.nodeId) chart.selectNodes([target.dataRef.nodeId], { additive: key === ' ' && event.shiftKey });
|
|
199
|
+
else if (target?.dataRef?.edgeId) chart.selectEdges([target.dataRef.edgeId], { additive: key === ' ' && event.shiftKey });
|
|
157
200
|
if (chart._keyboardConnection && target) {
|
|
158
201
|
const source = chart.model.scene.find(chart._keyboardConnection.targetId);
|
|
159
202
|
if (source) paintConnectPreview(chart, center(source), center(target));
|
|
160
203
|
chart.emit('connectionpreview', { chart, source: chart._keyboardConnection, target: target.dataRef, sourceType: 'keyboard' });
|
|
161
204
|
}
|
|
162
205
|
if (key !== 'tab') {
|
|
163
|
-
if (target?.id?.startsWith('port-') && chart.spec.editing?.enabled) {
|
|
206
|
+
if (target?.id?.startsWith('port-') && chart.spec.editing?.enabled && chart.spec.interaction?.portConnect) {
|
|
164
207
|
if (!chart._keyboardConnection) {
|
|
165
208
|
chart._keyboardConnection = { nodeId: target.dataRef.nodeId, portId: target.dataRef.portId, targetId: target.id };
|
|
166
209
|
paintConnectPreview(chart, center(target), center(target));
|
|
@@ -189,6 +232,7 @@ export function diagramKeyboard(chart, event) {
|
|
|
189
232
|
chart._diagramFocusTarget = target.id;
|
|
190
233
|
if (target.id.startsWith('node-')) chart.selectNodes([target.dataRef.nodeId]);
|
|
191
234
|
else if (target.id.startsWith('group-')) chart.selectGroup(target.dataRef.groupId);
|
|
235
|
+
else if (target.dataRef?.edgeId) chart.selectEdges([target.dataRef.edgeId]);
|
|
192
236
|
}
|
|
193
237
|
event.preventDefault();
|
|
194
238
|
return true;
|