@taylorwong/ichartjs 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/LICENSE +201 -0
- package/README.md +194 -0
- package/agent-recipes/diagrams/agent-orchestration.json +18 -0
- package/agent-recipes/diagrams/approval-process.json +16 -0
- package/agent-recipes/diagrams/responsibility-mapping.json +13 -0
- package/agent-recipes/diagrams/workflow.json +19 -0
- package/agent-recipes/foundational-analysis.json +18 -0
- package/agent-recipes/project-management.json +47 -0
- package/agent-recipes/trend-line.json +21 -0
- package/docs/agent/README.md +62 -0
- package/docs/agent/charting-scenario.md +82 -0
- package/docs/agent/coding-agent-integration.md +88 -0
- package/docs/agent/development/2.0-release-readiness.md +65 -0
- package/docs/agent/development/iteration-2.md +9 -0
- package/docs/agent/development/iteration-3.md +142 -0
- package/docs/agent/development/iteration-4.md +261 -0
- package/docs/agent/development/iteration-5.md +43 -0
- package/docs/agent/development/iteration-6.md +130 -0
- package/docs/agent/development/iteration-7.md +117 -0
- package/docs/agent/development/iteration-8-acceptance.md +68 -0
- package/docs/agent/development/iteration-8.md +116 -0
- package/docs/agent/development/iteration-9.md +44 -0
- package/docs/agent/development/playground-plan.md +143 -0
- package/docs/agent/development/prompt-contract.md +22 -0
- package/docs/agent/development/rc-1-acceptance.md +43 -0
- package/docs/agent/development/roadmap.md +259 -0
- package/docs/agent/development-guide.md +60 -0
- package/docs/agent/diagram-scenario.md +82 -0
- package/docs/agent/editing-contract.md +71 -0
- package/docs/agent/frontend-integration.md +66 -0
- package/docs/agent/project-scenario.md +89 -0
- package/docs/agent/quickstart.md +205 -0
- package/docs/agent/runtime-contract.md +132 -0
- package/docs/agent/theme-guide.md +59 -0
- package/docs/agent/zh-CN/README.md +26 -0
- package/docs/agent/zh-CN/charting-scenario.md +62 -0
- package/docs/agent/zh-CN/coding-agent-integration.md +36 -0
- package/docs/agent/zh-CN/development-guide.md +31 -0
- package/docs/agent/zh-CN/diagram-scenario.md +37 -0
- package/docs/agent/zh-CN/editing-contract.md +27 -0
- package/docs/agent/zh-CN/frontend-integration.md +31 -0
- package/docs/agent/zh-CN/project-scenario.md +40 -0
- package/docs/agent/zh-CN/quickstart.md +84 -0
- package/docs/agent/zh-CN/runtime-contract.md +85 -0
- package/docs/agent/zh-CN/theme-guide.md +50 -0
- package/docs/manifests/capabilities.json +79 -0
- package/docs/manifests/commands.json +30 -0
- package/docs/manifests/schemas.json +13 -0
- package/examples/agent-workflow.mjs +94 -0
- package/package.json +55 -0
- package/skills/ichartjs/SKILL.md +72 -0
- package/skills/ichartjs/agents/openai.yaml +4 -0
- package/skills/ichartjs/references/agent-contract.md +39 -0
- package/skills/ichartjs/references/chart-selection.md +22 -0
- package/src/capabilities.mjs +190 -0
- package/src/charts.mjs +234 -0
- package/src/command.mjs +93 -0
- package/src/data.mjs +75 -0
- package/src/diagram-interaction.mjs +195 -0
- package/src/diagram.mjs +135 -0
- package/src/edit-controller.mjs +192 -0
- package/src/edit.mjs +369 -0
- package/src/format.mjs +19 -0
- package/src/history.mjs +14 -0
- package/src/index.mjs +562 -0
- package/src/plugin.mjs +18 -0
- package/src/project-analytics.mjs +357 -0
- package/src/project-linking.mjs +72 -0
- package/src/project.mjs +370 -0
- package/src/recipes.mjs +18 -0
- package/src/renderer.mjs +77 -0
- package/src/scale.mjs +9 -0
- package/src/scene.mjs +18 -0
- package/src/schema.mjs +142 -0
- package/src/spec.mjs +127 -0
- package/src/theme.mjs +232 -0
- package/src/transforms.mjs +34 -0
- package/src/validation.mjs +106 -0
- package/types/index.d.ts +134 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public iChart.js 2.0 runtime entry point.
|
|
3
|
+
* Exposes chart lifecycle, rendering, interaction, Agent editing, and discovery.
|
|
4
|
+
*/
|
|
5
|
+
import { normalizeSpec, validateSpec } from './spec.mjs';
|
|
6
|
+
import { normalizeData, inspectData, data } from './data.mjs';
|
|
7
|
+
import { binData, applyTransforms } from './transforms.mjs';
|
|
8
|
+
import { buildScene } from './charts.mjs';
|
|
9
|
+
import { CanvasRenderer, SVGRenderer } from './renderer.mjs';
|
|
10
|
+
import { contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast } from './theme.mjs';
|
|
11
|
+
import { PluginHost, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin } from './plugin.mjs';
|
|
12
|
+
import { projectTooltip, rerouteDiagramScene } from './project.mjs';
|
|
13
|
+
import { getBusinessSchema, inspectDataSchema } from './schema.mjs';
|
|
14
|
+
import { validateData } from './validation.mjs';
|
|
15
|
+
import { commitPreview, getEditCapabilities, previewEdit, validateEdit } from './edit.mjs';
|
|
16
|
+
import { EditHistory } from './history.mjs';
|
|
17
|
+
import { validateRecipe } from './recipes.mjs';
|
|
18
|
+
import { EditController } from './edit-controller.mjs';
|
|
19
|
+
import { diagramLayoutModes, edgeRoutingModes, normalizeDiagramSpec, validateDiagram, layoutDiagram, routeEdge } from './diagram.mjs';
|
|
20
|
+
import { normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries } from './project-analytics.mjs';
|
|
21
|
+
import { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId } from './project-linking.mjs';
|
|
22
|
+
import { explainChart, getCapabilities as discoverCapabilities, getChartCapability, planChart } from './capabilities.mjs';
|
|
23
|
+
|
|
24
|
+
import { isDiagram, paintSelection, diagramPointer, diagramKeyboard } from './diagram-interaction.mjs';
|
|
25
|
+
|
|
26
|
+
function resolveContainer(container) { return typeof container === 'string' ? document.querySelector(container) : container; }
|
|
27
|
+
function clone(value) { return value == null ? value : JSON.parse(JSON.stringify(value)); }
|
|
28
|
+
export function resolveZoomWindow(count, current, factor) {
|
|
29
|
+
const total = Math.max(0, Math.round(Number(count) || 0));
|
|
30
|
+
if (total <= 1) return { start: 0, end: total };
|
|
31
|
+
const start = Math.max(0, Math.min(total, Number(current?.start) || 0)), end = Math.max(start, Math.min(total, Number(current?.end) || total));
|
|
32
|
+
const currentSpan = Math.max(2, end - start), nextSpan = Math.max(2, Math.min(total, Math.round(currentSpan * factor)));
|
|
33
|
+
const center = (start + end) / 2;
|
|
34
|
+
const nextStart = Math.max(0, Math.min(total - nextSpan, Math.round(center - nextSpan / 2)));
|
|
35
|
+
return { start: nextStart, end: nextStart + nextSpan };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function _svgEscape(value, mode = 'text') {
|
|
39
|
+
const raw = value == null ? '' : String(value);
|
|
40
|
+
let out = raw.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
41
|
+
if (mode === 'attr') out = out.replace(/"/g, '"').replace(/'/g, ''');
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
function _svgStyleFontParts(raw = '') {
|
|
45
|
+
const str = String(raw).trim();
|
|
46
|
+
let size = '', weight = '', family = '';
|
|
47
|
+
const sizeMatch = str.match(/\b(\d+(?:\.\d+)?)\s*(px|em|rem|pt|%)/i);
|
|
48
|
+
if (sizeMatch) size = `${sizeMatch[1]}${sizeMatch[2].toLowerCase()}`;
|
|
49
|
+
const weightMatch = str.match(/^\s*(\d{3}|normal|bold|lighter|bolder)\b/i);
|
|
50
|
+
if (weightMatch) weight = weightMatch[1];
|
|
51
|
+
family = str
|
|
52
|
+
.replace(/^\s*(?:(?:normal|italic|oblique)(?:\s+[^0-9\s]+)?\s+)?(?:\d{3}|normal|bold|lighter|bolder)\s+/i, '')
|
|
53
|
+
.replace(/\b\d+(?:\.\d+)?\s*(?:px|em|rem|pt|%)(?:\s*\/\s*\S+)?\s*/i, '')
|
|
54
|
+
.trim();
|
|
55
|
+
return { size, weight, family };
|
|
56
|
+
}
|
|
57
|
+
function sceneToSvgString(scene, spec = {}) {
|
|
58
|
+
const w = Number(scene?.width ?? spec?.width ?? 640);
|
|
59
|
+
const h = Number(scene?.height ?? spec?.height ?? 360);
|
|
60
|
+
const bg = spec.background ?? '#ffffff';
|
|
61
|
+
const lines = [`<?xml version="1.0" encoding="UTF-8"?>`, `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`];
|
|
62
|
+
if (bg) lines.push(` <rect x="0" y="0" width="${w}" height="${h}" fill="${_svgEscape(bg, 'attr')}"/>`);
|
|
63
|
+
const renderNode = (node, indent = ' ') => {
|
|
64
|
+
if (!node || !node.visible) return;
|
|
65
|
+
if (node.type === 'root') {
|
|
66
|
+
const kids = [...node.children].sort((a, b) => a.zIndex - b.zIndex);
|
|
67
|
+
kids.forEach(k => renderNode(k, indent));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const g = node.geometry || {}; const s = node.style || {};
|
|
71
|
+
const attrs = [];
|
|
72
|
+
if (node.id) attrs.push(`id="${_svgEscape(node.id, 'attr')}" data-node-id="${_svgEscape(node.id, 'attr')}"`);
|
|
73
|
+
if (node.dataRef) attrs.push(`data-data-ref="${_svgEscape(JSON.stringify(node.dataRef), 'attr')}"`);
|
|
74
|
+
const css = [];
|
|
75
|
+
let tag = node.type;
|
|
76
|
+
if (node.type === 'rect') {
|
|
77
|
+
attrs.push(`x="${g.x}" y="${g.y}" width="${g.width}" height="${g.height}"`);
|
|
78
|
+
} else if (node.type === 'line') {
|
|
79
|
+
attrs.push(`x1="${g.x1}" y1="${g.y1}" x2="${g.x2}" y2="${g.y2}"`);
|
|
80
|
+
} else if (node.type === 'circle') {
|
|
81
|
+
attrs.push(`cx="${g.cx}" cy="${g.cy}" r="${g.r}"`);
|
|
82
|
+
} else if (node.type === 'arc') {
|
|
83
|
+
tag = 'path';
|
|
84
|
+
const large = g.end - g.start > Math.PI ? 1 : 0;
|
|
85
|
+
const outerStart = `${g.cx + g.r * Math.cos(g.start)} ${g.cy + g.r * Math.sin(g.start)}`;
|
|
86
|
+
const outerEnd = `${g.cx + g.r * Math.cos(g.end)} ${g.cy + g.r * Math.sin(g.end)}`;
|
|
87
|
+
let d;
|
|
88
|
+
if (g.innerR > 0) {
|
|
89
|
+
d = [`M ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, `L ${g.cx + g.innerR * Math.cos(g.end)} ${g.cy + g.innerR * Math.sin(g.end)}`, `A ${g.innerR} ${g.innerR} 0 ${large} 0 ${g.cx + g.innerR * Math.cos(g.start)} ${g.cy + g.innerR * Math.sin(g.start)}`, 'Z'].join(' ');
|
|
90
|
+
} else {
|
|
91
|
+
d = [`M ${g.cx} ${g.cy}`, `L ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, 'Z'].join(' ');
|
|
92
|
+
}
|
|
93
|
+
attrs.push(`d="${_svgEscape(d, 'attr')}"`);
|
|
94
|
+
} else if (node.type === 'path') {
|
|
95
|
+
const d = `${(g.points || []).map((p, i) => `${i ? 'L' : 'M'} ${p.x} ${p.y}`).join(' ')}${g.closed ? ' Z' : ''}`;
|
|
96
|
+
attrs.push(`d="${_svgEscape(d, 'attr')}"`);
|
|
97
|
+
} else if (node.type === 'text') {
|
|
98
|
+
attrs.push(`x="${g.x}" y="${g.y}"`);
|
|
99
|
+
const anchor = s.textAnchor || (s.textAlign === 'end' ? 'end' : s.textAlign === 'center' ? 'middle' : s.textAlign === 'right' ? 'end' : s.textAlign === 'left' ? 'start' : 'start');
|
|
100
|
+
if (anchor) attrs.push(`text-anchor="${anchor}"`);
|
|
101
|
+
const baseline = s.textBaseline || s.baseline || 'alphabetic';
|
|
102
|
+
if (baseline === 'top') { attrs.push(`dominant-baseline="text-before-edge"`); attrs.push(`alignment-baseline="before-edge"`); }
|
|
103
|
+
else if (baseline === 'middle' || baseline === 'central') { attrs.push(`dominant-baseline="middle"`); attrs.push(`alignment-baseline="middle"`); }
|
|
104
|
+
else if (baseline === 'bottom' || baseline === 'hanging') { attrs.push(`dominant-baseline="text-after-edge"`); attrs.push(`alignment-baseline="after-edge"`); }
|
|
105
|
+
else { attrs.push(`dominant-baseline="alphabetic"`); attrs.push(`alignment-baseline="alphabetic"`); }
|
|
106
|
+
if (s.font) {
|
|
107
|
+
const rawFont = String(s.font).trim();
|
|
108
|
+
attrs.push(`font="${_svgEscape(rawFont, 'attr')}"`);
|
|
109
|
+
const { size, weight, family } = _svgStyleFontParts(rawFont);
|
|
110
|
+
if (size) css.push(`font-size:${size}`);
|
|
111
|
+
if (weight) css.push(`font-weight:${weight}`);
|
|
112
|
+
if (family) css.push(`font-family:${family}`);
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
tag = 'g';
|
|
116
|
+
}
|
|
117
|
+
if (node.type === 'path' && !s.fill) attrs.push(`fill="none"`);
|
|
118
|
+
else if (s.fill) attrs.push(`fill="${_svgEscape(s.fill, 'attr')}"`);
|
|
119
|
+
if (s.stroke) attrs.push(`stroke="${_svgEscape(s.stroke, 'attr')}"`);
|
|
120
|
+
if (s.strokeWidth) attrs.push(`stroke-width="${s.strokeWidth}"`);
|
|
121
|
+
if (s.opacity != null) {
|
|
122
|
+
const opacity = (node.highlighted || node.selected) ? 1 : s.opacity;
|
|
123
|
+
attrs.push(`opacity="${opacity}"`);
|
|
124
|
+
}
|
|
125
|
+
if (node.highlighted || node.selected) attrs.push(`filter="brightness(1.2)"`);
|
|
126
|
+
if (node.interactive) css.push('cursor:pointer');
|
|
127
|
+
if (s.pointerEvents === 'none') css.push('pointer-events:none');
|
|
128
|
+
if (s.ariaHidden === 'true') attrs.push(`aria-hidden="true"`);
|
|
129
|
+
if (s.role === 'presentation') attrs.push(`role="presentation"`);
|
|
130
|
+
if (node.decorative) {
|
|
131
|
+
attrs.push(`aria-hidden="true"`);
|
|
132
|
+
attrs.push(`role="presentation"`);
|
|
133
|
+
css.push('pointer-events:none', 'user-select:none');
|
|
134
|
+
}
|
|
135
|
+
if (css.length) attrs.push(`style="${_svgEscape(css.join(';'), 'attr')}"`);
|
|
136
|
+
if (node.type === 'text') {
|
|
137
|
+
lines.push(`${indent}<${tag} ${attrs.join(' ')}>${_svgEscape(g.text ?? '')}</${tag}>`);
|
|
138
|
+
} else {
|
|
139
|
+
lines.push(`${indent}<${tag} ${attrs.join(' ')}/>`);
|
|
140
|
+
}
|
|
141
|
+
const kids = [...(node.children || [])].sort((a, b) => a.zIndex - b.zIndex);
|
|
142
|
+
kids.forEach(k => renderNode(k, indent + ' '));
|
|
143
|
+
};
|
|
144
|
+
renderNode(scene.root);
|
|
145
|
+
lines.push(`</svg>`);
|
|
146
|
+
return lines.join('\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export class Chart {
|
|
150
|
+
constructor(input = {}) {
|
|
151
|
+
const result = validateSpec(input);
|
|
152
|
+
if (!result.valid) { const error = new Error(result.errors.map(item => item.message).join(' ')); error.details = result.errors; throw error; }
|
|
153
|
+
this._specDiagnostics = { warnings: result.warnings, normalizations: result.normalizations };
|
|
154
|
+
this._themeInput = input.theme ?? 'auto';
|
|
155
|
+
this._styleOverrides = { colors: input.colors !== undefined, background: input.background !== undefined, padding: input.padding !== undefined };
|
|
156
|
+
this.spec = result.spec;
|
|
157
|
+
this._resolveStyle();
|
|
158
|
+
this.container = typeof document === 'undefined' ? null : resolveContainer(this.spec.container);
|
|
159
|
+
this.listeners = new Map();
|
|
160
|
+
this._selected = new Map();
|
|
161
|
+
this._clipboard = { nodes: [], edges: [] };
|
|
162
|
+
this._history = new EditHistory();
|
|
163
|
+
this._revision = 0;
|
|
164
|
+
this._lastChangeSet = null;
|
|
165
|
+
this._editor = new EditController(this);
|
|
166
|
+
this.plugins = new PluginHost(this, this.spec.plugins);
|
|
167
|
+
this._mountRenderer();
|
|
168
|
+
this._observeResize();
|
|
169
|
+
this._observeColorScheme();
|
|
170
|
+
this.render();
|
|
171
|
+
}
|
|
172
|
+
_resolveStyle() {
|
|
173
|
+
this.spec.theme = resolveTheme(this._themeInput, this.spec);
|
|
174
|
+
if (!this._styleOverrides.colors) this.spec.colors = [...this.spec.theme.colors];
|
|
175
|
+
if (!this._styleOverrides.background) this.spec.background = this.spec.theme.background;
|
|
176
|
+
if (!this._styleOverrides.padding) this.spec.padding = clone(this.spec.theme.layout.padding);
|
|
177
|
+
}
|
|
178
|
+
_observeColorScheme() {
|
|
179
|
+
if (typeof matchMedia !== 'function') return;
|
|
180
|
+
this._colorSchemeQuery = matchMedia('(prefers-color-scheme: dark)');
|
|
181
|
+
this._colorSchemeHandler = () => {
|
|
182
|
+
const requestedMode = typeof this._themeInput === 'string' ? this._themeInput : this._themeInput?.mode;
|
|
183
|
+
if ((requestedMode || 'auto') !== 'auto') return;
|
|
184
|
+
this._resolveStyle();
|
|
185
|
+
this.render();
|
|
186
|
+
this.emit('themechange', { chart: this, theme: this.getTheme(), source: 'system' });
|
|
187
|
+
};
|
|
188
|
+
this._colorSchemeQuery.addEventListener?.('change', this._colorSchemeHandler);
|
|
189
|
+
}
|
|
190
|
+
_mountRenderer() { const rendererType = this.spec.renderer === 'auto' ? 'canvas' : this.spec.renderer; this.renderer = rendererType === 'svg' ? new SVGRenderer(this.spec) : new CanvasRenderer(this.spec); if (this.container && typeof document !== 'undefined') this.renderer.mount(this.container); }
|
|
191
|
+
render() { this.model = buildScene(this.spec); paintSelection(this); this._addInteractionNodes(); this.plugins.beforeRender(this.model); if (this.renderer.container) { this.renderer.options = this.spec; const target = this.renderer.svg || this.renderer.canvas; if (target) target.style.background = this.spec.background; this.renderer.resize(this.spec.width, this.spec.height); this.renderer.render(this.model.scene); this._bindEvents(); this._applyAccessibility(); } this.plugins.afterRender(this.model); this.emit('render', { chart: this }); return this; }
|
|
192
|
+
_addInteractionNodes() { if (this.spec.interaction?.crosshair && this.model.state?.plot) { const plot = this.model.state.plot, stroke = this.spec.theme?.focus || '#64748b'; this.model.scene.add({ id: 'crosshair-x', type: 'line', geometry: { x1: plot.x, y1: plot.y, x2: plot.x, y2: plot.y + plot.height }, style: { stroke, strokeWidth: 1, opacity: 0 }, interactive: false, zIndex: 99 }); this.model.scene.add({ id: 'crosshair-y', type: 'line', geometry: { x1: plot.x, y1: plot.y, x2: plot.x + plot.width, y2: plot.y }, style: { stroke, strokeWidth: 1, opacity: 0 }, interactive: false, zIndex: 99 }); } }
|
|
193
|
+
_observeResize() { if (!this.container || typeof ResizeObserver === 'undefined') return; this._resizeObserver = new ResizeObserver(entries => { const width = Math.round(entries[0]?.contentRect?.width || 0); if (width && width !== this.spec.width) this.resize(width, this.spec.height); }); this._resizeObserver.observe(this.container); }
|
|
194
|
+
_applyAccessibility() {
|
|
195
|
+
if (!this.container) return;
|
|
196
|
+
const target = this.renderer.svg || this.renderer.canvas;
|
|
197
|
+
if (this.spec.accessibility?.enabled || isDiagram(this)) {
|
|
198
|
+
target.setAttribute?.('role', isDiagram(this) ? 'group' : 'img');
|
|
199
|
+
target.setAttribute?.('aria-label', this.getAccessibleDescription());
|
|
200
|
+
if (isDiagram(this) && this._keyboardConnection) target.setAttribute?.('aria-description', `Connecting from port ${this._keyboardConnection.portId} on node ${this._keyboardConnection.nodeId}. Tab to another port, Enter to connect, Escape to cancel.`);
|
|
201
|
+
else target.removeAttribute?.('aria-description');
|
|
202
|
+
target.setAttribute?.('tabindex', '0');
|
|
203
|
+
if (this.renderer.svg && isDiagram(this)) {
|
|
204
|
+
[...this.renderer.svg.querySelectorAll('[data-node-id]')].forEach(element => {
|
|
205
|
+
const dataRef = element.dataset.dataRef ? JSON.parse(element.dataset.dataRef) : {};
|
|
206
|
+
if (dataRef.groupId && element.id.startsWith('group-')) {
|
|
207
|
+
element.setAttribute('role', 'group');
|
|
208
|
+
element.setAttribute('aria-label', `Group ${dataRef.groupId}${dataRef.collapsed ? ', collapsed' : ', expanded'}`);
|
|
209
|
+
element.setAttribute('aria-expanded', String(!dataRef.collapsed));
|
|
210
|
+
} else if (dataRef.edgeId || dataRef.from && dataRef.to) {
|
|
211
|
+
element.setAttribute('role', 'img');
|
|
212
|
+
element.setAttribute('aria-label', `Edge from ${dataRef.from} to ${dataRef.to}`);
|
|
213
|
+
if (dataRef.from) element.setAttribute('aria-describedby', `node-${dataRef.from}`);
|
|
214
|
+
} else if (dataRef.nodeId && !dataRef.portId) {
|
|
215
|
+
element.setAttribute('role', 'button');
|
|
216
|
+
element.setAttribute('aria-label', `Node ${dataRef.nodeId}`);
|
|
217
|
+
if (dataRef.groupId) element.setAttribute('aria-describedby', `group-${dataRef.groupId}`);
|
|
218
|
+
element.setAttribute('aria-selected', String(this.getSelectedNodeIds().includes(dataRef.nodeId)));
|
|
219
|
+
} else if (dataRef.portId) {
|
|
220
|
+
element.setAttribute('role', 'button');
|
|
221
|
+
element.setAttribute('aria-label', `Port ${dataRef.portId} on node ${dataRef.nodeId}`);
|
|
222
|
+
element.setAttribute('aria-describedby', `node-${dataRef.nodeId}`);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (!this._keyboardBound && this.spec.interaction?.keyboard !== false) {
|
|
227
|
+
this._keyboardHandler = event => {
|
|
228
|
+
if (diagramKeyboard(this, event)) return;
|
|
229
|
+
if (!['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(event.key)) return;
|
|
230
|
+
const ids = [];
|
|
231
|
+
this.model.scene.walk(node => { if (node.interactive && node.dataRef) ids.push(node.id); });
|
|
232
|
+
if (!ids.length) return;
|
|
233
|
+
if (['flow', 'swimlane'].includes(this.spec.type) && this.spec.editing?.enabled && this._selected.size) {
|
|
234
|
+
const delta = { x: ['ArrowRight', 'ArrowLeft'].includes(event.key) ? (event.key === 'ArrowRight' ? 8 : -8) : 0, y: ['ArrowDown', 'ArrowUp'].includes(event.key) ? (event.key === 'ArrowDown' ? 8 : -8) : 0 };
|
|
235
|
+
const result = this.moveSelectedBy(delta, { confirmed: true, source: 'keyboard' });
|
|
236
|
+
if (result.valid) event.preventDefault();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const direction = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1;
|
|
240
|
+
this._focusIndex = Math.max(0, Math.min(ids.length - 1, (this._focusIndex ?? 0) + direction));
|
|
241
|
+
const node = this.model.scene.find(ids[this._focusIndex]);
|
|
242
|
+
if (node) {
|
|
243
|
+
this.model.scene.walk(item => { item.highlighted = false; });
|
|
244
|
+
node.highlighted = true;
|
|
245
|
+
this.renderer.render(this.model.scene);
|
|
246
|
+
this.emit('focuschange', { chart: this, target: node, datum: node.dataRef.dataIndex == null ? undefined : this.model.data.rows[node.dataRef.dataIndex] });
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
target.addEventListener('keydown', this._keyboardHandler);
|
|
250
|
+
this._keyboardBound = true;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
_bindEvents() { if (this._eventsBound || !this.renderer.container) return; const target = this.renderer.svg || this.renderer.canvas; const handler = event => { if (event.type === 'click' && isDiagram(this) && this._suppressDiagramClick) { this._suppressDiagramClick = false; return; } const point = this._eventPoint(event, target), node = this.model.scene.hit(point.x, point.y), dataIndex = node?.dataRef?.dataIndex == null ? undefined : node.dataRef.dataIndex + (this.model.data.viewOffset || 0), datum = node?.dataRef?.datum || (dataIndex === undefined ? undefined : normalizeData(this.spec.data).rows[dataIndex]); const payload = { type: event.type === 'click' ? 'click' : 'hover', chart: this, nativeEvent: event, target: node, dataRef: node?.dataRef ? { ...node.dataRef, dataIndex } : undefined, datum, coordinate: point }; if (payload.type === 'hover') { this._pointer = point; this._updateCrosshair(point.x, point.y); this.emit('hover', payload); } else if (node) { this.emit('click', payload); if (!isDiagram(this)) this.select(node, { additive: Boolean(event.shiftKey || event.metaKey || event.ctrlKey) }); } if (payload.type === 'hover' && this.spec.interaction.tooltip) { if (node) this._showTooltip(payload); else this._hideTooltip(); } };
|
|
255
|
+
const start = event => { if (diagramPointer(this, 'start', event, target)) return; const point = this._eventPoint(event, target), node = this.model.scene.hit(point.x, point.y); target.setPointerCapture?.(event.pointerId); if (this.spec.type === 'flow' && this.spec.interaction?.drag && node?.id?.startsWith('node-')) { this._nodeDrag = { node, point }; return; } if (this.spec.interaction?.brush) this._brushStart = point; else if (this.spec.interaction?.pan) { this._dragStart = point; this.emit('dragstart', { chart: this, coordinate: point, target: node }); } };
|
|
256
|
+
const move = event => { if (diagramPointer(this, 'move', event, target)) return; const point = this._eventPoint(event, target); if (this._brushStart) { this._brushEnd = point; this.emit('brush', { chart: this, start: this._brushStart, end: this._brushEnd }); } if (this._nodeDrag) { const { node, point: previous } = this._nodeDrag, dx = point.x - previous.x, dy = point.y - previous.y; node.geometry.x += dx; node.geometry.y += dy; node.bounds = { ...node.geometry }; const label = this.model.scene.find(`node-label-${node.dataRef.nodeId}`); if (label) { label.geometry.x += dx; label.geometry.y += dy; } const sourceNode = (this.spec.nodes || this.spec.data.nodes || []).find(item => item.id === node.dataRef.nodeId); if (sourceNode) sourceNode.position = { x: node.geometry.x, y: node.geometry.y }; rerouteDiagramScene(this.model.scene); this._nodeDrag.point = point; this.renderer.render(this.model.scene); this.emit('drag', { chart: this, target: node, coordinate: point }); } else if (this._dragStart) { this.panBy({ x: point.x - this._dragStart.x, y: point.y - this._dragStart.y }); this._dragStart = point; } };
|
|
257
|
+
const end = event => { if (diagramPointer(this, 'end', event, target)) return; if (this._brushStart && this._brushEnd) this.selectBox(this._brushStart, this._brushEnd); if (this._nodeDrag) this.emit('dragend', { chart: this, target: this._nodeDrag.node }); if (this._dragStart) this.emit('dragend', { chart: this, coordinate: this._eventPoint(event, target) }); target.releasePointerCapture?.(event.pointerId); this._brushStart = null; this._brushEnd = null; this._dragStart = null; this._nodeDrag = null; this._pinchStart = null; };
|
|
258
|
+
const touchStart = event => { if (isDiagram(this)) return; if (event.touches.length === 1) { const point = this._eventPoint(event.touches[0], target), node = this.model.scene.hit(point.x, point.y); this._touchStart = { x: event.touches[0].clientX, y: event.touches[0].clientY }; if (this.spec.type === 'flow' && this.spec.interaction?.drag && node?.id?.startsWith('node-')) this._nodeDrag = { node, point }; } if (event.touches.length === 2) { this._nodeDrag = null; this._pinchStart = this._touchDistance(event); } };
|
|
259
|
+
const touchMove = event => { if (isDiagram(this)) return; event.preventDefault(); if (event.touches.length === 2 && this.spec.interaction?.zoom && this._pinchStart) { const distance = this._touchDistance(event); if (distance) { const factor = this._pinchStart / distance; if (Math.abs(1 - factor) > 0.04) { const count = normalizeData(this.spec.data).rows.length, current = this.spec.view || { start: 0, end: count }; if (count > 1) this.zoomTo(resolveZoomWindow(count, current, factor)); else this.zoomTo({ scale: Math.max(0.5, Math.min(4, (this.spec.view?.scale || 1) / factor)) }); this._pinchStart = distance; } } } else if (event.touches.length === 1 && this._touchStart) { const touch = event.touches[0]; if (this._nodeDrag) move({ clientX: touch.clientX, clientY: touch.clientY }); else if (this.spec.interaction?.pan) { this.panBy({ x: touch.clientX - this._touchStart.x, y: touch.clientY - this._touchStart.y }); this._touchStart = { x: touch.clientX, y: touch.clientY }; } } };
|
|
260
|
+
const touchEnd = () => { this._touchStart = null; this._pinchStart = null; };
|
|
261
|
+
const wheel = event => { if (!this.spec.interaction.zoom) return; event.preventDefault(); if (isDiagram(this)) { this.zoomTo({ scale: Math.max(0.25, Math.min(8, (this.spec.view?.scale || 1) * (event.deltaY > 0 ? 0.9 : 1.1))) }); return; } const count = normalizeData(this.spec.data).rows.length, current = this.spec.view || { start: 0, end: count }; this.zoomTo(resolveZoomWindow(count, current, event.deltaY > 0 ? 1.25 : 0.8)); };
|
|
262
|
+
const leave = event => { this._hideTooltip(); if (!this._dragStart && !this._nodeDrag) end(event); };
|
|
263
|
+
if (isDiagram(this)) target.style.touchAction = 'none'; target.addEventListener('mousemove', handler); target.addEventListener('click', handler); target.addEventListener('pointerdown', start); target.addEventListener('pointermove', move); target.addEventListener('pointerup', end); target.addEventListener('pointercancel', end); target.addEventListener('pointerleave', leave); target.addEventListener('touchstart', touchStart, { passive: false }); target.addEventListener('touchmove', touchMove, { passive: false }); target.addEventListener('touchend', touchEnd); target.addEventListener('wheel', wheel, { passive: false }); this._eventsBound = { target, handler, start, move, end, leave, touchStart, touchMove, touchEnd, wheel }; }
|
|
264
|
+
_touchDistance(event) { if (!event.touches || event.touches.length < 2) return null; const [first, second] = event.touches; return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY); }
|
|
265
|
+
_eventPoint(event, target) { const rect = target.getBoundingClientRect(); return { x: (event.clientX - rect.left) * this.spec.width / (rect.width || this.spec.width), y: (event.clientY - rect.top) * this.spec.height / (rect.height || this.spec.height) }; }
|
|
266
|
+
_updateCrosshair(x, y) { const vertical = this.model.scene.find('crosshair-x'), horizontal = this.model.scene.find('crosshair-y'); if (!vertical || !horizontal) return; vertical.geometry.x1 = vertical.geometry.x2 = x; vertical.style.opacity = 0.7; horizontal.geometry.y1 = horizontal.geometry.y2 = y; horizontal.style.opacity = 0.7; this.renderer.render(this.model.scene); }
|
|
267
|
+
_showTooltip(payload) { if (typeof document === 'undefined' || !this.container || !payload.datum) return; let tip = this._tooltip; if (!tip) { tip = this._tooltip = document.createElement('div'); tip.className = 'ichart-v2-tooltip'; Object.assign(tip.style, { position: 'fixed', pointerEvents: 'none', zIndex: 9999, padding: '8px 10px', borderRadius: '6px', maxWidth: 'min(320px, calc(100vw - 24px))', whiteSpace: 'pre-line' }); document.body.appendChild(tip); } Object.assign(tip.style, { background: this.spec.theme.surface, color: this.spec.theme.text, border: `1px solid ${this.spec.theme.border}`, font: this.spec.theme.typography.tooltip.font, boxShadow: `0 4px 12px ${this.spec.theme.border}88` }); const text = ['gantt', 'timeline', 'milestone', 'burndown'].includes(this.spec.type) || ['flow', 'swimlane'].includes(this.spec.type) ? projectTooltip(this.spec.type, payload.datum) : Object.entries(payload.datum).map(([key, value]) => `${key}: ${value}`).join(' · '); tip.textContent = text; const left = Math.min((payload.nativeEvent.clientX || 0) + 12, window.innerWidth - tip.offsetWidth - 12); const top = Math.min((payload.nativeEvent.clientY || 0) + 12, window.innerHeight - tip.offsetHeight - 12); tip.style.left = `${Math.max(12, left)}px`; tip.style.top = `${Math.max(12, top)}px`; }
|
|
268
|
+
_hideTooltip() { if (this._tooltip) this._tooltip.style.left = '-10000px'; }
|
|
269
|
+
on(type, listener) { if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type).add(listener); return this; }
|
|
270
|
+
off(type, listener) { this.listeners.get(type)?.delete(listener); return this; }
|
|
271
|
+
emit(type, event) { this.listeners.get(type)?.forEach(listener => listener(event)); }
|
|
272
|
+
update(next = {}) { this._editor.invalidate(); if (next.theme !== undefined) this._themeInput = next.theme; ['colors', 'background', 'padding'].forEach(key => { if (next[key] !== undefined) this._styleOverrides[key] = true; }); this.spec = normalizeSpec({ ...this.spec, ...next, theme: this._themeInput, data: next.data === undefined ? this.spec.data : next.data }); this._resolveStyle(); return this.render(); }
|
|
273
|
+
setData(data) { this._editor.invalidate(); this.spec = normalizeSpec({ ...this.spec, theme: this._themeInput, data: { values: data } }); this._resolveStyle(); return this.render(); }
|
|
274
|
+
setTheme(theme = 'auto') { this._themeInput = theme; this._resolveStyle(); this.render(); this.emit('themechange', { chart: this, theme: this.getTheme(), source: 'user' }); return this; }
|
|
275
|
+
getTheme() { return clone(this.spec.theme); }
|
|
276
|
+
resize(width = this.spec.width, height = this.spec.height) { this.spec.width = width; this.spec.height = height; this.render(); this.emit('resize', { chart: this, width, height }); return this; }
|
|
277
|
+
getSpec() { return JSON.parse(JSON.stringify(this.spec)); }
|
|
278
|
+
getState() { const brandingSignature = discoverCapabilities().branding.signature; return { renderer: this.renderer.constructor.name, width: this.spec.width, height: this.spec.height, dataCount: this.model.data.rows.length, selected: [...this._selected.values()], revision: this._revision, history: this._history.state(), view: clone(this.spec.view || null), style: clone({ name: this.spec.theme.name, mode: this.spec.theme.mode, resolvedMode: this.spec.theme.resolvedMode, preset: this.spec.theme.preset, palette: this.spec.theme.palette, reasons: this.spec.theme.reasons }), branding: { enabled: Boolean(this.spec.branding?.enabled), signature: brandingSignature, text: this.spec.branding?.enabled === true ? brandingSignature : null }, warnings: clone([...(this._specDiagnostics?.warnings || []), ...(this.model.data?.warnings || []), ...(this.spec.theme?.warnings || [])]), assumptions: clone(this.model.data?.assumptions || []), normalizations: clone(this._specDiagnostics?.normalizations || []), collapsedGroups: this.getCollapsedGroupIds(), clipboard: { nodes: this._clipboard?.nodes?.length || 0, edges: this._clipboard?.edges?.length || 0 }, projectAnalytics: clone(this.model.state?.projectAnalytics || null), linked: clone(this.model.state?.linked || null) }; }
|
|
279
|
+
getProjectAnalytics() { return clone(this.model.state?.projectAnalytics || null); }
|
|
280
|
+
getLinkedState() { return clone(this.model.state?.linked || null); }
|
|
281
|
+
setLinkedFilters(filters = {}) { this.spec.project = { ...(this.spec.project || {}), linked: { ...(this.spec.project?.linked || {}), filters: normalizeLinkedFilters(filters) } }; this.emit('linkedstatechange', { chart: this, linked: this.spec.project.linked }); return this.render(); }
|
|
282
|
+
setLinkedSelection(selection = []) { this.spec.project = { ...(this.spec.project || {}), linked: { ...(this.spec.project?.linked || {}), selection: normalizeLinkedSelection(selection) } }; this.emit('linkedstatechange', { chart: this, linked: this.spec.project.linked }); return this.render(); }
|
|
283
|
+
describe() { return { type: this.spec.type, renderer: this.renderer.constructor.name, dimensions: [this.spec.encoding.x?.field || this.spec.encoding.category?.field], measures: (Array.isArray(this.spec.encoding.y) ? this.spec.encoding.y : [this.spec.encoding.y || this.spec.encoding.value]).filter(Boolean).map(encoding => encoding.field), dataCount: this.model.data.rows.length, theme: this.spec.theme?.name || 'custom', interactions: Object.keys(this.spec.interaction || {}).filter(key => this.spec.interaction[key]) }; }
|
|
284
|
+
explain() { return explainChart(this.spec, this.model); }
|
|
285
|
+
getAccessibleDescription() { const description = this.spec.accessibility?.description || this.spec.title?.text || `${this.spec.type} chart`; return `${description}; ${this.model.data.rows.length} data items.`; }
|
|
286
|
+
inspectDataSchema() { return inspectDataSchema(this.spec.data.schema || this.spec.schema); }
|
|
287
|
+
validateData() { return validateData(this.toDataTable(), this.spec.data.schema || this.spec.schema, this.spec.validationOptions); }
|
|
288
|
+
validateEdit(command) { return validateEdit(command, { values: this.toDataTable(), schema: this.spec.data.schema || this.spec.schema, validationOptions: this.spec.validationOptions, requireConfirmation: this.spec.editing?.requireConfirmation }); }
|
|
289
|
+
previewEdit(command) { return this._editor.preview(command); }
|
|
290
|
+
applyEdit(command, options = {}) { return this._editor.apply(command, options); }
|
|
291
|
+
getChangeSet() { return this._lastChangeSet ? JSON.parse(JSON.stringify(this._lastChangeSet)) : null; }
|
|
292
|
+
undo(options = {}) { return this._editor.restore('undo', options); }
|
|
293
|
+
redo(options = {}) { return this._editor.restore('redo', options); }
|
|
294
|
+
use(plugin) { this.plugins.use(plugin); return this.render(); }
|
|
295
|
+
highlight(target) { if (target?.id) target.highlighted = true; this.render(); return this; }
|
|
296
|
+
select(target, options = {}) { if (!options.additive) { this._selected.clear(); this.model?.scene.walk(node => { node.selected = false; }); } if (target?.id) { target.selected = true; this._selected.set(target.id, target.dataRef); } this.emit('selectionchange', { chart: this, target, selectedData: this.getSelectedData() }); return this; }
|
|
297
|
+
selectNodes(nodeIds = [], options = {}) { if (!options.additive) { this._selected.clear(); this.model.scene.walk(node => { node.selected = false; }); } nodeIds.forEach(id => { const node = this.model.scene.find(`node-${id}`); if (node) { node.selected = true; this._selected.set(node.id, node.dataRef); } }); this.emit('selectionchange', { chart: this, target: null, selectedData: this.getSelectedData() }); this.render(); return this; }
|
|
298
|
+
selectGroup(groupId, options = {}) { return this.selectNodes(this.model.data.rows.filter(row => row.groupId === groupId).map(row => row.id), options); }
|
|
299
|
+
getSelectedNodeIds() { return [...new Set([...this._selected.values()].map(ref => ref?.nodeId).filter(Boolean))]; }
|
|
300
|
+
getDiagramNodes() { return (this.spec.nodes || this.spec.data?.nodes || []).map(node => ({ ...node })); }
|
|
301
|
+
getDiagramEdges() { return (this.spec.edges || this.spec.data?.edges || []).map(edge => ({ ...edge })); }
|
|
302
|
+
getDiagramGroups() { return (this.spec.groups || this.spec.data?.groups || []).map(group => ({ ...group })); }
|
|
303
|
+
getCollapsedGroupIds() { return this.getDiagramGroups().filter(group => group.collapsed).map(group => group.id); }
|
|
304
|
+
copySelection() {
|
|
305
|
+
const nodeIds = this.getSelectedNodeIds();
|
|
306
|
+
if (!nodeIds.length) return { valid: false, errors: [{ code: 'NO_SELECTION', message: 'Select one or more diagram nodes first.' }] };
|
|
307
|
+
const selected = new Set(nodeIds), nodes = this.getDiagramNodes().filter(node => selected.has(node.id)), edges = this.getDiagramEdges().filter(edge => selected.has(edge.from) && selected.has(edge.to));
|
|
308
|
+
this._clipboard = { nodes, edges };
|
|
309
|
+
this.emit('clipboardchange', { chart: this, clipboard: this.getClipboard() });
|
|
310
|
+
return { valid: true, clipboard: this.getClipboard() };
|
|
311
|
+
}
|
|
312
|
+
getClipboard() { return { nodes: (this._clipboard?.nodes || []).map(node => ({ ...node })), edges: (this._clipboard?.edges || []).map(edge => ({ ...edge })) }; }
|
|
313
|
+
pasteSelection(options = {}) {
|
|
314
|
+
if (!this._clipboard?.nodes?.length) return { valid: false, errors: [{ code: 'EMPTY_CLIPBOARD', message: 'Copy diagram nodes before pasting.' }] };
|
|
315
|
+
const before = new Set(this.getDiagramNodes().map(node => node.id));
|
|
316
|
+
const preview = this.previewEdit({ type: 'layout-edit', reason: 'Paste selection', operations: [{ op: 'pasteSelection', nodes: this._clipboard.nodes, edges: this._clipboard.edges, ...(options.offset ? { offset: options.offset } : {}) }] });
|
|
317
|
+
const result = this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed });
|
|
318
|
+
if (result.valid) {
|
|
319
|
+
const inserted = this.getDiagramNodes().filter(node => !before.has(node.id)).map(node => node.id);
|
|
320
|
+
if (inserted.length) this.selectNodes(inserted);
|
|
321
|
+
this.emit('clipboardchange', { chart: this, clipboard: this.getClipboard(), pastedNodeIds: inserted });
|
|
322
|
+
}
|
|
323
|
+
return result;
|
|
324
|
+
}
|
|
325
|
+
duplicateSelection(options = {}) {
|
|
326
|
+
const nodeIds = this.getSelectedNodeIds();
|
|
327
|
+
if (!nodeIds.length) return { valid: false, errors: [{ code: 'NO_SELECTION', message: 'Select one or more diagram nodes first.' }] };
|
|
328
|
+
const before = new Set(this.getDiagramNodes().map(node => node.id));
|
|
329
|
+
const preview = this.previewEdit({ type: 'layout-edit', reason: 'Duplicate selection', operations: [{ op: 'duplicateSelection', nodeIds, ...(options.offset ? { offset: options.offset } : {}) }] });
|
|
330
|
+
const result = this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed });
|
|
331
|
+
if (result.valid) {
|
|
332
|
+
const inserted = this.getDiagramNodes().filter(node => !before.has(node.id)).map(node => node.id);
|
|
333
|
+
if (inserted.length) this.selectNodes(inserted);
|
|
334
|
+
}
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
337
|
+
connectNodes(connection, options = {}) {
|
|
338
|
+
const operation = { op: 'addEdge', from: connection.from, to: connection.to, ...(connection.id ? { id: connection.id } : {}), ...(connection.fromPort ? { fromPort: connection.fromPort } : {}), ...(connection.toPort ? { toPort: connection.toPort } : {}), ...(connection.label ? { label: connection.label } : {}), ...(connection.status ? { status: connection.status } : {}), ...(connection.routing ? { routing: connection.routing } : {}) };
|
|
339
|
+
const preview = this.previewEdit({ type: 'layout-edit', reason: 'Connect diagram nodes', operations: [operation] });
|
|
340
|
+
return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed });
|
|
341
|
+
}
|
|
342
|
+
toggleGroupCollapse(groupId, options = {}) {
|
|
343
|
+
const preview = this.previewEdit({ type: 'layout-edit', reason: 'Toggle group collapse', operations: [{ op: 'toggleGroupCollapse', groupId, ...(options.collapsed !== undefined ? { collapsed: options.collapsed } : {}) }] });
|
|
344
|
+
const result = this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed });
|
|
345
|
+
if (result.valid) this.emit('groupcollapsechange', { chart: this, groupId, collapsedGroups: this.getCollapsedGroupIds() });
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
moveGroupBy(groupId, delta, options = {}) { const preview = this.previewEdit({ type: 'layout-edit', reason: options.reason || 'Move diagram group', operations: [{ op: 'moveGroup', groupId, delta }] }); return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed }); }
|
|
349
|
+
resizeGroup(groupId, size, options = {}) { const preview = this.previewEdit({ type: 'layout-edit', reason: 'Resize diagram group members', operations: [{ op: 'resizeGroup', groupId, size }] }); return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed }); }
|
|
350
|
+
assignSelectedToGroup(groupId, options = {}) { const nodeIds = this.getSelectedNodeIds(); if (!nodeIds.length) return { valid: false, errors: [{ code: 'NO_SELECTION', message: 'Select one or more diagram nodes first.' }] }; const preview = this.previewEdit({ type: 'layout-edit', reason: groupId === null ? 'Remove nodes from diagram group' : 'Assign nodes to diagram group', operations: [{ op: 'assignNodesToGroup', nodeIds, groupId }] }); return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed }); }
|
|
351
|
+
duplicateGroup(groupId, options = {}) { const before = new Set(this.getDiagramGroups().map(group => group.id)); const preview = this.previewEdit({ type: 'layout-edit', reason: 'Duplicate diagram group', operations: [{ op: 'duplicateGroup', groupId, ...(options.offset ? { offset: options.offset } : {}) }] }); const result = this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed }); if (result.valid) { const insertedGroup = this.getDiagramGroups().find(group => !before.has(group.id)); if (insertedGroup) this.selectGroup(insertedGroup.id); } return result; }
|
|
352
|
+
deleteGroup(groupId, options = {}) { const preview = this.previewEdit({ type: 'layout-edit', reason: 'Delete diagram group', operations: [{ op: 'deleteGroup', groupId, policy: options.policy || 'ungroup' }] }); const result = this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed }); if (result.valid) this.clearSelection(); return result; }
|
|
353
|
+
moveSelectedBy(delta, options = {}) { const nodeIds = this.getSelectedNodeIds(); if (!nodeIds.length) return { valid: false, errors: [{ code: 'NO_SELECTION', message: 'Select one or more diagram nodes first.' }] }; const preview = this.previewEdit({ type: 'layout-edit', reason: options.reason || 'Move selected nodes', operations: [{ op: 'moveNodes', nodeIds, delta }] }); return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed }); }
|
|
354
|
+
alignSelected(alignment, options = {}) { const nodeIds = this.getSelectedNodeIds(); const preview = this.previewEdit({ type: 'layout-edit', reason: `Align selected nodes ${alignment}`, operations: [{ op: 'alignNodes', nodeIds, alignment }] }); return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed }); }
|
|
355
|
+
snapSelected(options = {}) { const nodeIds = this.getSelectedNodeIds(); const preview = this.previewEdit({ type: 'layout-edit', reason: 'Snap selected nodes', operations: [{ op: 'snapNodes', nodeIds }] }); return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed }); }
|
|
356
|
+
getSelectedData() { const diagram = ['flow', 'swimlane'].includes(this.spec.type), source = diagram ? (this.spec.nodes || this.spec.data?.nodes || []) : normalizeData(this.spec.data).rows, offset = diagram ? 0 : (this.model.data.viewOffset || 0); return [...this._selected.values()].map(ref => diagram ? source.find(row => row.id === ref?.nodeId) : (ref?.dataIndex == null ? ref?.datum : source[ref.dataIndex + offset])).filter(Boolean); }
|
|
357
|
+
clearSelection() { this._selected.clear(); this.render(); this.emit('selectionchange', { chart: this, target: null, selectedData: [] }); return this; }
|
|
358
|
+
getElementAt(x, y) { return this.model.scene.hit(x, y); }
|
|
359
|
+
selectBox(start, end) { const left = Math.min(start.x, end.x), right = Math.max(start.x, end.x), top = Math.min(start.y, end.y), bottom = Math.max(start.y, end.y); this._selected.clear(); this.model.scene.walk(node => { node.selected = false; if (!node.dataRef || !node.bounds || !node.interactive || (isDiagram(this) && !node.id.startsWith('node-'))) return; const intersects = node.bounds.x <= right && node.bounds.x + node.bounds.width >= left && node.bounds.y <= bottom && node.bounds.y + node.bounds.height >= top; if (intersects) { node.selected = true; this._selected.set(node.id, node.dataRef); } }); this.emit('selectionchange', { chart: this, selectedData: this.getSelectedData(), box: { start, end } }); this.render(); return this; }
|
|
360
|
+
toDataTable() { return this.model.data.rows.map(row => ({ ...row })); }
|
|
361
|
+
applyPatch(patches = []) { patches.forEach(patch => { const path = patch.path.replace(/^\//, '').split('/'); let target = this.spec; path.slice(0, -1).forEach(key => { target = target[key]; }); if (patch.op === 'remove') delete target[path.at(-1)]; else target[path.at(-1)] = patch.value; }); return this.render(); }
|
|
362
|
+
resetZoom() { delete this.spec.view; return this.render(); }
|
|
363
|
+
zoomTo(view) { this.spec.view = { ...view }; this.emit('zoomchange', { chart: this, view: this.spec.view }); return this.render(); }
|
|
364
|
+
panBy(delta) { const current = this.spec.view || {}; this.spec.view = { ...current, offsetX: (current.offsetX || 0) + (delta.x || 0), offsetY: (current.offsetY || 0) + (delta.y || 0) }; this.emit('zoomchange', { chart: this, view: this.spec.view }); return this.render(); }
|
|
365
|
+
toDataURL(type = 'image/png') {
|
|
366
|
+
const rawType = String(type || 'image/png').toLowerCase();
|
|
367
|
+
const wantsSvg = rawType.includes('svg') || rawType.includes('xml');
|
|
368
|
+
if (this.renderer instanceof SVGRenderer) {
|
|
369
|
+
if (wantsSvg) {
|
|
370
|
+
const headlessEnv = typeof document === 'undefined' || typeof XMLSerializer === 'undefined';
|
|
371
|
+
const string = headlessEnv ? sceneToSvgString(this.model.scene, this.spec) : this.renderer.exportString();
|
|
372
|
+
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(string)}`;
|
|
373
|
+
}
|
|
374
|
+
if (typeof document !== 'undefined') {
|
|
375
|
+
const cv = document.createElement('canvas');
|
|
376
|
+
const r = new CanvasRenderer({ ...this.spec, container: undefined });
|
|
377
|
+
r.canvas = cv; r.ctx = cv.getContext('2d');
|
|
378
|
+
r.resize(this.spec.width, this.spec.height);
|
|
379
|
+
if (this.spec.background) { r.ctx.fillStyle = this.spec.background; r.ctx.fillRect(0, 0, this.spec.width, this.spec.height); }
|
|
380
|
+
r.render(this.model.scene);
|
|
381
|
+
return r.exportImage(type);
|
|
382
|
+
}
|
|
383
|
+
return { valid: false, code: 'HEADLESS_EXPORT_UNSUPPORTED', rasterCode: 'RASTER_EXPORT_UNSUPPORTED', message: `SVG renderer raster ('${type}') in headless requires npm i canvas, or fall back to chart.export({type:"svg"}).`, suggestion: 'Use chart.export({type:"svg"}) for headless, or render with { renderer: "canvas" }.' };
|
|
384
|
+
}
|
|
385
|
+
if (this.renderer instanceof CanvasRenderer && typeof this.renderer.exportImage === 'function') return this.renderer.exportImage(type);
|
|
386
|
+
return { valid: false, code: 'EXPORT_UNSUPPORTED', message: `toDataURL('${type}') is not supported with renderer ${this.renderer?.constructor?.name || 'unknown'}.`, suggestion: 'Mount the chart to a DOM container, or switch renderer=canvas for PNG export.' };
|
|
387
|
+
}
|
|
388
|
+
toBlob(type = 'image/png') {
|
|
389
|
+
if (typeof document === 'undefined') return { valid: false, code: 'BLOB_HEADLESS', message: 'toBlob() requires a browser document. Use toDataURL() or export({type:"svg"}) in headless.', suggestion: 'Use chart.export({type:"svg"}) or chart.toDataURL() which both return strings usable in headless.' };
|
|
390
|
+
const url = this.toDataURL(type);
|
|
391
|
+
if (!url || typeof url !== 'string') return { valid: false, code: 'EXPORT_UNSUPPORTED', message: "Couldn't produce a data URL for export.", suggestion: (url && url.message) || url };
|
|
392
|
+
const mime = type.includes('svg') ? 'image/svg+xml' : /^image\//i.test(type) ? type : 'image/png';
|
|
393
|
+
const comma = url.indexOf(',');
|
|
394
|
+
const base64 = comma >= 0 && /;base64$/i.test(url.slice(0, comma));
|
|
395
|
+
const payload = comma >= 0 ? url.slice(comma + 1) : url;
|
|
396
|
+
const bytes = base64 ? Uint8Array.from(atob(payload), c => c.charCodeAt(0)) : (new TextEncoder()).encode(decodeURIComponent(payload));
|
|
397
|
+
return new Blob([bytes], { type: mime });
|
|
398
|
+
}
|
|
399
|
+
export(options = {}) {
|
|
400
|
+
const rawType = String(options.type || (this.renderer instanceof CanvasRenderer ? 'image/png' : 'image/svg+xml')).toLowerCase();
|
|
401
|
+
const normalizeType = t => {
|
|
402
|
+
if (t === 'json' || t === 'application/json' || t.endsWith('/json')) return 'json';
|
|
403
|
+
if (t.includes('svg')) return 'svg';
|
|
404
|
+
if (t.includes('png')) return 'png';
|
|
405
|
+
if (t.includes('jpeg') || t.includes('jpg')) return 'jpeg';
|
|
406
|
+
return 'auto';
|
|
407
|
+
};
|
|
408
|
+
const kind = normalizeType(rawType);
|
|
409
|
+
if (kind === 'json') {
|
|
410
|
+
const payload = { version: '2.0', spec: this.getSpec(), state: this.getState() };
|
|
411
|
+
return options.as === 'object' ? payload : JSON.stringify(payload, null, 2);
|
|
412
|
+
}
|
|
413
|
+
if (kind === 'svg') {
|
|
414
|
+
const svgRendererReady = this.renderer instanceof SVGRenderer && this.renderer.container;
|
|
415
|
+
const headlessEnv = typeof document === 'undefined' || typeof XMLSerializer === 'undefined';
|
|
416
|
+
if (headlessEnv) {
|
|
417
|
+
const string = sceneToSvgString(this.model.scene, this.spec);
|
|
418
|
+
if (options.as === 'dataurl') return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(string)}`;
|
|
419
|
+
if (options.as === 'blob') return { valid: false, code: 'BLOB_HEADLESS', message: 'as=blob requires a browser runtime (Blob). Use as=string or as=dataurl in headless.', suggestion: 'chart.export({type:"svg"}) returns the SVG string directly.' };
|
|
420
|
+
return string;
|
|
421
|
+
}
|
|
422
|
+
const svgRenderer = svgRendererReady
|
|
423
|
+
? this.renderer
|
|
424
|
+
: (() => { const r = new SVGRenderer(this.spec); r.resize(this.spec.width, this.spec.height); r.render(this.model.scene); return r; })();
|
|
425
|
+
const string = svgRenderer.exportString();
|
|
426
|
+
if (options.as === 'dataurl') return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(string)}`;
|
|
427
|
+
if (options.as === 'blob') {
|
|
428
|
+
if (typeof Blob === 'undefined') return { valid: false, code: 'BLOB_HEADLESS', message: 'as=blob requires a browser runtime. Use as=string or as=dataurl in headless.', suggestion: 'chart.export({type:"svg"}) returns the SVG string directly.' };
|
|
429
|
+
return new Blob([string], { type: 'image/svg+xml' });
|
|
430
|
+
}
|
|
431
|
+
return string;
|
|
432
|
+
}
|
|
433
|
+
const rasterType = kind === 'jpeg' ? 'image/jpeg' : 'image/png';
|
|
434
|
+
if (this.renderer instanceof CanvasRenderer && typeof this.renderer.exportImage === 'function' && this.renderer.canvas) {
|
|
435
|
+
const dataUrl = this.renderer.exportImage(rasterType);
|
|
436
|
+
if (options.as === 'blob') {
|
|
437
|
+
if (typeof document === 'undefined' || typeof Blob === 'undefined' || typeof atob !== 'function') return { valid: false, code: 'BLOB_HEADLESS', message: 'PNG as=blob requires a browser runtime (atob + Blob). Use as=dataurl or as=string in headless.', suggestion: 'In Node headless, install the canvas package and render via a mounted CanvasRenderer, or fall back to chart.export({type:"svg"}).' };
|
|
438
|
+
const comma = dataUrl.indexOf(',');
|
|
439
|
+
const bytes = Uint8Array.from(atob(dataUrl.slice(comma + 1)), c => c.charCodeAt(0));
|
|
440
|
+
return new Blob([bytes], { type: rasterType });
|
|
441
|
+
}
|
|
442
|
+
return dataUrl;
|
|
443
|
+
}
|
|
444
|
+
if (this.renderer instanceof SVGRenderer && typeof document !== 'undefined') {
|
|
445
|
+
const cv = document.createElement('canvas');
|
|
446
|
+
const r = new CanvasRenderer({ ...this.spec, container: undefined });
|
|
447
|
+
r.canvas = cv; r.ctx = cv.getContext('2d');
|
|
448
|
+
r.resize(this.spec.width, this.spec.height);
|
|
449
|
+
if (this.spec.background) { r.ctx.fillStyle = this.spec.background; r.ctx.fillRect(0, 0, this.spec.width, this.spec.height); }
|
|
450
|
+
r.render(this.model.scene);
|
|
451
|
+
const dataUrl = r.exportImage(rasterType);
|
|
452
|
+
if (options.as === 'blob') {
|
|
453
|
+
if (typeof Blob === 'undefined' || typeof atob !== 'function') return { valid: false, code: 'BLOB_UNSUPPORTED', message: 'as=blob requires Blob + atob.' };
|
|
454
|
+
const comma = dataUrl.indexOf(',');
|
|
455
|
+
const bytes = Uint8Array.from(atob(dataUrl.slice(comma + 1)), c => c.charCodeAt(0));
|
|
456
|
+
return new Blob([bytes], { type: rasterType });
|
|
457
|
+
}
|
|
458
|
+
return dataUrl;
|
|
459
|
+
}
|
|
460
|
+
if (this.renderer instanceof SVGRenderer) {
|
|
461
|
+
try {
|
|
462
|
+
const c = require('canvas');
|
|
463
|
+
if (c && c.createCanvas) {
|
|
464
|
+
const cv = c.createCanvas(this.spec.width, this.spec.height);
|
|
465
|
+
const r = new CanvasRenderer({ ...this.spec });
|
|
466
|
+
r.canvas = cv; r.ctx = cv.getContext('2d'); r.resize(this.spec.width, this.spec.height); r.render(this.model.scene);
|
|
467
|
+
const out = r.exportImage(rasterType);
|
|
468
|
+
return options.as === 'blob' ? (typeof Blob !== 'undefined' ? new Blob([Uint8Array.from(atob(out.slice(out.indexOf(',') + 1)), x => x.charCodeAt(0))], { type: rasterType }) : out) : out;
|
|
469
|
+
}
|
|
470
|
+
} catch (_) { /* ignore */ }
|
|
471
|
+
return { valid: false, code: 'HEADLESS_EXPORT_UNSUPPORTED', rasterCode: 'RASTER_EXPORT_UNSUPPORTED', message: `Raster export (${rasterType}) with SVG renderer in headless requires npm i canvas.`, suggestion: 'Install the `canvas` package, or fall back to chart.export({type:"svg"}) which works browser+headless with zero dependencies.' };
|
|
472
|
+
}
|
|
473
|
+
const canvasUnavailable = { valid: false, code: 'HEADLESS_EXPORT_UNSUPPORTED', rasterCode: 'RASTER_EXPORT_UNSUPPORTED', message: `Raster export (${rasterType}) requires a mounted CanvasRenderer with a real canvas element.`, suggestion: 'Mount the chart with { renderer: "canvas", container: "#id" } in a browser, or fall back to chart.export({type:"svg"}) which works browser+headless.' };
|
|
474
|
+
if (typeof document === 'undefined') {
|
|
475
|
+
try {
|
|
476
|
+
const c = require('canvas');
|
|
477
|
+
if (c && c.createCanvas) {
|
|
478
|
+
const cv = c.createCanvas(this.spec.width, this.spec.height);
|
|
479
|
+
const r = new CanvasRenderer({ ...this.spec });
|
|
480
|
+
r.canvas = cv; r.ctx = cv.getContext('2d'); r.resize(this.spec.width, this.spec.height); r.render(this.model.scene);
|
|
481
|
+
const out = r.exportImage(rasterType);
|
|
482
|
+
return options.as === 'blob' ? (typeof Blob !== 'undefined' ? new Blob([Uint8Array.from(atob(out.slice(out.indexOf(',') + 1)), x => x.charCodeAt(0))], { type: rasterType }) : out) : out;
|
|
483
|
+
}
|
|
484
|
+
} catch (_) { /* ignore: continue to structured error */ }
|
|
485
|
+
return { ...canvasUnavailable, headless: true, note: 'In Node headless, run `npm i canvas` and ensure createCanvas() is available, or use chart.export({type:"svg"}).' };
|
|
486
|
+
}
|
|
487
|
+
return canvasUnavailable;
|
|
488
|
+
}
|
|
489
|
+
async exportAsync(options = {}) {
|
|
490
|
+
const rawType = String(options.type || (this.renderer instanceof CanvasRenderer ? 'image/png' : 'image/svg+xml')).toLowerCase();
|
|
491
|
+
const normalizeType = t => {
|
|
492
|
+
if (t === 'json' || t === 'application/json' || t.endsWith('/json')) return 'json';
|
|
493
|
+
if (t.includes('svg')) return 'svg';
|
|
494
|
+
if (t.includes('png')) return 'png';
|
|
495
|
+
if (t.includes('jpeg') || t.includes('jpg')) return 'jpeg';
|
|
496
|
+
return 'auto';
|
|
497
|
+
};
|
|
498
|
+
const kind = normalizeType(rawType);
|
|
499
|
+
if (kind === 'json' || kind === 'svg' || kind === 'png' || kind === 'jpeg') {
|
|
500
|
+
const out = this.export(options);
|
|
501
|
+
if (out && typeof out === 'object' && out.valid === false) return Promise.reject(out);
|
|
502
|
+
return Promise.resolve(out);
|
|
503
|
+
}
|
|
504
|
+
return Promise.reject({ valid: false, code: 'EXPORT_TYPE_UNSUPPORTED', message: `Unknown export type: ${rawType}`, suggestion: 'Use png, svg, jpeg, or json.' });
|
|
505
|
+
}
|
|
506
|
+
_safeFilename(prefix = 'ichart') {
|
|
507
|
+
const sanitize = s => String(s == null ? '' : s).replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || prefix;
|
|
508
|
+
const title = sanitize(this.spec.title?.text || this.spec.type || 'chart');
|
|
509
|
+
const ts = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
|
|
510
|
+
return `${prefix}-${title}-${ts}`;
|
|
511
|
+
}
|
|
512
|
+
download(options = {}) {
|
|
513
|
+
if (typeof document === 'undefined' || typeof URL === 'undefined') {
|
|
514
|
+
const kind = (options.type || options.format || 'auto').toString().toLowerCase();
|
|
515
|
+
if (kind.includes('svg')) return this.export({ type: 'svg' });
|
|
516
|
+
if (kind.includes('json')) return this.export({ type: 'json' });
|
|
517
|
+
return { valid: false, code: 'DOWNLOAD_HEADLESS', message: 'chart.download*() triggers a browser save-as dialog; in headless, use chart.export() directly.', suggestion: 'Use chart.export({type:"svg|json"}) (strings) or chart.toDataURL() (data URL) in headless.' };
|
|
518
|
+
}
|
|
519
|
+
const kind = (options.type || options.format || 'png').toString().toLowerCase();
|
|
520
|
+
if (kind.includes('svg')) {
|
|
521
|
+
const svg = this.export({ type: 'svg' });
|
|
522
|
+
if (!svg || typeof svg !== 'string') return svg;
|
|
523
|
+
return this._downloadBlob(new Blob([svg], { type: 'image/svg+xml' }), `${this._safeFilename('ichartjs')}.svg`);
|
|
524
|
+
}
|
|
525
|
+
if (kind.includes('json')) {
|
|
526
|
+
const json = this.export({ type: 'json' });
|
|
527
|
+
return this._downloadBlob(new Blob([json], { type: 'application/json' }), `${this._safeFilename('ichartjs')}.json`);
|
|
528
|
+
}
|
|
529
|
+
const rasterType = kind.includes('jpeg') || kind.includes('jpg') ? 'image/jpeg' : 'image/png';
|
|
530
|
+
const ext = kind.includes('jpeg') || kind.includes('jpg') ? 'jpg' : 'png';
|
|
531
|
+
const blob = this.toBlob(rasterType);
|
|
532
|
+
if (!blob || blob instanceof Blob === false) return blob;
|
|
533
|
+
return this._downloadBlob(blob, `${this._safeFilename('ichartjs')}.${ext}`);
|
|
534
|
+
}
|
|
535
|
+
downloadPNG() { return this.download({ type: 'png' }); }
|
|
536
|
+
downloadSVG() { return this.download({ type: 'svg' }); }
|
|
537
|
+
downloadJSON() { return this.download({ type: 'json' }); }
|
|
538
|
+
_downloadBlob(blob, filename) {
|
|
539
|
+
try {
|
|
540
|
+
const url = URL.createObjectURL(blob);
|
|
541
|
+
const a = document.createElement('a');
|
|
542
|
+
a.href = url; a.download = filename; a.rel = 'noopener';
|
|
543
|
+
document.body.appendChild(a); a.click();
|
|
544
|
+
setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 0);
|
|
545
|
+
return { valid: true, filename, size: blob.size, type: blob.type };
|
|
546
|
+
} catch (err) {
|
|
547
|
+
return { valid: false, code: 'DOWNLOAD_FAILED', message: String(err && err.message || err), suggestion: 'Try chart.export() and write the result manually.' };
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
destroy() { if (this._resizeObserver) this._resizeObserver.disconnect(); this._colorSchemeQuery?.removeEventListener?.('change', this._colorSchemeHandler); if (this._eventsBound) { const { target, handler, start, move, end, leave, touchStart, touchMove, touchEnd, wheel } = this._eventsBound; target.removeEventListener('mousemove', handler); target.removeEventListener('click', handler); target.removeEventListener('pointerdown', start); target.removeEventListener('pointermove', move); target.removeEventListener('pointerup', end); target.removeEventListener('pointercancel', end); target.removeEventListener('pointerleave', leave); target.removeEventListener('touchstart', touchStart); target.removeEventListener('touchmove', touchMove); target.removeEventListener('touchend', touchEnd); target.removeEventListener('wheel', wheel); } const target = this.renderer.svg || this.renderer.canvas; if (this._keyboardHandler) target?.removeEventListener('keydown', this._keyboardHandler); this._tooltip?.remove(); this.plugins.destroy(); this.renderer.destroy(); this.listeners.clear(); this._eventsBound = null; this._selected.clear(); this._clipboard = { nodes: [], edges: [] }; }
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
export function createChart(spec) { return new Chart(spec); }
|
|
554
|
+
export function getCapabilities() { return { ...discoverCapabilities(), diagram: { layoutModes: diagramLayoutModes, routingModes: edgeRoutingModes, entities: ['node', 'edge', 'lane', 'group', 'port'], operations: ['moveNode', 'moveNodes', 'resizeNode', 'alignNodes', 'snapNodes', 'moveNodeToLane', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'], validation: ['duplicate-ids', 'missing-endpoints', 'missing-ports', 'missing-lanes'] }, editing: { modes: ['preview', 'commit', 'undo', 'redo'], operations: ['updateField', 'updateRecord', 'updateTask', 'shiftTask', 'updateProgress', 'addDependency', 'removeDependency', 'updateMilestone', 'moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'], confirmationRequiredByDefault: true, commit: 'local-runtime-host-persistence-required' } }; }
|
|
555
|
+
export function recommend(input, options = {}) { const plan = planChart(input, options); return { primary: plan.primary, alternatives: plan.alternatives, reason: plan.reasons.join(' '), reasons: plan.reasons, confidence: plan.confidence, requiredFields: plan.requiredFields, assumptions: plan.assumptions, warnings: plan.warnings, nextActions: plan.nextActions }; }
|
|
556
|
+
export { normalizeSpec, validateSpec, normalizeData, inspectData, binData, applyTransforms, data, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, getChartCapability, planChart, explainChart };
|
|
557
|
+
export { diagramLayoutModes, edgeRoutingModes, normalizeDiagramSpec, validateDiagram, layoutDiagram, routeEdge };
|
|
558
|
+
export { normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries };
|
|
559
|
+
export { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };
|
|
560
|
+
|
|
561
|
+
export { contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin };
|
|
562
|
+
export const iChart = { version: '2.0.0', createChart, inspectData, normalizeData, binData, applyTransforms, normalizeSpec, validateSpec, data, getCapabilities, getChartCapability, planChart, recommend, explainChart, contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries, normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };
|
package/src/plugin.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer-independent plugin lifecycle host and built-in plugin factories.
|
|
3
|
+
* Plugins extend chart behavior without changing the core scene contract.
|
|
4
|
+
*/
|
|
5
|
+
import { SceneNode } from './scene.mjs';
|
|
6
|
+
|
|
7
|
+
export class PluginHost {
|
|
8
|
+
constructor(chart, plugins = []) { this.chart = chart; this.plugins = []; plugins.forEach(plugin => this.use(plugin)); }
|
|
9
|
+
use(plugin) { if (!plugin || this.plugins.includes(plugin)) return this; this.plugins.push(plugin); plugin.install?.(this.chart); return this; }
|
|
10
|
+
beforeRender(model) { this.plugins.forEach(plugin => plugin.beforeRender?.(this.chart, model)); }
|
|
11
|
+
afterRender(model) { this.plugins.forEach(plugin => plugin.afterRender?.(this.chart, model)); }
|
|
12
|
+
destroy() { this.plugins.slice().reverse().forEach(plugin => plugin.destroy?.(this.chart)); this.plugins = []; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function annotationPlugin(annotations = []) { return { name: 'annotation', beforeRender(chart, model) { annotations.forEach((annotation, index) => { if (annotation.type === 'line') model.scene.add(new SceneNode({ id: `annotation-line-${index}`, type: 'line', geometry: annotation.geometry, style: { stroke: annotation.color || '#dc2626', strokeWidth: annotation.width || 1 } })); }); } }; }
|
|
16
|
+
export function dataZoomPlugin(options = {}) { return { name: 'dataZoom', install(chart) { chart.spec.interaction = { ...chart.spec.interaction, zoom: true }; chart.zoomOptions = { minSpan: options.minSpan || 2 }; } }; }
|
|
17
|
+
export function dataLabelsPlugin(options = {}) { return { name: 'dataLabels', beforeRender(chart, model) { const additions = []; model.scene.walk(node => { if (!node.dataRef || !node.geometry) return; const datum = model.data.rows[node.dataRef.dataIndex]; const yEncoding = Array.isArray(chart.spec.encoding.y) ? chart.spec.encoding.y[node.dataRef.seriesIndex] : chart.spec.encoding.y; const valueField = node.dataRef.field || yEncoding?.field || chart.spec.encoding.value?.field || 'value'; const value = datum?.[valueField]; if (value === undefined) return; const x = node.geometry.x ?? node.geometry.cx; const y = node.geometry.y ?? node.geometry.cy; if (x === undefined || y === undefined) return; additions.push(new SceneNode({ id: `${node.id}-label`, type: 'text', geometry: { x, y: y - 6, text: options.formatter ? options.formatter(value, datum) : String(value) }, style: { fill: options.color || '#334155', font: options.font || '11px system-ui', textAnchor: 'middle' } })); }); additions.forEach(node => model.scene.add(node)); } }; }
|
|
18
|
+
export function accessibilityPlugin(options = {}) { return { name: 'accessibility', install(chart) { chart.spec.accessibility = { ...chart.spec.accessibility, enabled: true, ...options }; } }; }
|