@taylorwong/ichartjs 2.0.5 → 2.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +11 -4
- package/docs/agent/README.md +3 -1
- package/docs/agent/coding-agent-integration.md +6 -2
- package/docs/agent/development/iteration-11.md +44 -0
- package/docs/agent/development/iteration-12.md +101 -0
- package/docs/agent/development/roadmap.md +29 -3
- 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 +63 -0
- package/docs/agent/usage-scenarios.md +40 -1
- package/docs/agent/zh-CN/README.md +1 -1
- package/docs/agent/zh-CN/coding-agent-integration.md +6 -2
- 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 +63 -0
- package/docs/agent/zh-CN/usage-scenarios.md +40 -1
- package/docs/manifests/capabilities.json +37 -2
- package/docs/manifests/commands.json +10 -9
- package/docs/manifests/schemas.json +5 -2
- package/package.json +1 -1
- package/skills/ichartjs/SKILL.md +11 -2
- package/src/capabilities.mjs +65 -8
- 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 +68 -17
- package/src/layout.mjs +39 -0
- package/src/preferences-ui.mjs +135 -0
- package/src/preferences.mjs +194 -0
- 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 +39 -3
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small, renderer-neutral preference contract shared by the Chart API,
|
|
3
|
+
* interactive settings UI, and Agent integrations.
|
|
4
|
+
*/
|
|
5
|
+
import { themeModes, themePalettes, themePresets } from './theme.mjs';
|
|
6
|
+
|
|
7
|
+
export const preferenceVersion = '1.0';
|
|
8
|
+
export const preferenceDensities = ['compact', 'comfortable', 'spacious'];
|
|
9
|
+
export const preferenceTriStates = ['auto', true, false];
|
|
10
|
+
export const preferenceMotions = ['auto', 'full', 'reduced', 'off'];
|
|
11
|
+
|
|
12
|
+
export const defaultPreferences = Object.freeze({
|
|
13
|
+
version: preferenceVersion,
|
|
14
|
+
theme: Object.freeze({ mode: null, preset: null, palette: null }),
|
|
15
|
+
typography: Object.freeze({ scale: 1 }),
|
|
16
|
+
density: 'comfortable',
|
|
17
|
+
components: Object.freeze({ legend: 'auto', labels: 'auto', grid: 'auto' }),
|
|
18
|
+
branding: Object.freeze({ enabled: 'auto' }),
|
|
19
|
+
motion: 'auto'
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function clone(value) { return value == null ? value : JSON.parse(JSON.stringify(value)); }
|
|
23
|
+
|
|
24
|
+
function merge(base, extra) {
|
|
25
|
+
const result = clone(base) || {};
|
|
26
|
+
Object.entries(extra || {}).forEach(([key, value]) => {
|
|
27
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) result[key] = merge(result[key], value);
|
|
28
|
+
else if (value !== undefined) result[key] = clone(value);
|
|
29
|
+
});
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); }
|
|
34
|
+
|
|
35
|
+
function diagnostic(code, path, message, expected, suggestion) {
|
|
36
|
+
return { code, path, message, ...(expected === undefined ? {} : { expected }), ...(suggestion ? { suggestion } : {}) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validatePreferences(input = {}, { partial = true } = {}) {
|
|
40
|
+
const errors = [];
|
|
41
|
+
if (!isPlainObject(input)) return { valid: false, errors: [diagnostic('INVALID_PREFERENCES', '', 'Preferences must be an object.', 'an object', 'Pass a preferences object.')], value: clone(defaultPreferences) };
|
|
42
|
+
const theme = input.theme;
|
|
43
|
+
if (theme !== undefined) {
|
|
44
|
+
if (!isPlainObject(theme)) errors.push(diagnostic('INVALID_PREFERENCE_THEME', 'theme', 'Preferences theme must be an object.', 'an object'));
|
|
45
|
+
else {
|
|
46
|
+
if (theme.mode !== undefined && theme.mode !== null && !themeModes.includes(theme.mode)) errors.push(diagnostic('INVALID_PREFERENCE_THEME_MODE', 'theme.mode', `Unsupported preference theme mode: ${theme.mode}.`, themeModes));
|
|
47
|
+
if (theme.preset !== undefined && theme.preset !== null && !themePresets.includes(theme.preset)) errors.push(diagnostic('INVALID_PREFERENCE_THEME_PRESET', 'theme.preset', `Unsupported preference theme preset: ${theme.preset}.`, themePresets));
|
|
48
|
+
if (theme.palette !== undefined && theme.palette !== null && !themePalettes.includes(theme.palette)) errors.push(diagnostic('INVALID_PREFERENCE_THEME_PALETTE', 'theme.palette', `Unsupported preference theme palette: ${theme.palette}.`, themePalettes));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const scale = input.typography?.scale;
|
|
52
|
+
if (scale !== undefined && (!(Number.isFinite(Number(scale))) || Number(scale) < 0.75 || Number(scale) > 1.5)) errors.push(diagnostic('INVALID_PREFERENCE_SCALE', 'typography.scale', 'Typography scale must be between 0.75 and 1.5.', '0.75..1.5', 'Use 1 for the default size.'));
|
|
53
|
+
if (input.density !== undefined && !preferenceDensities.includes(input.density)) errors.push(diagnostic('INVALID_PREFERENCE_DENSITY', 'density', `Unsupported density: ${input.density}.`, preferenceDensities));
|
|
54
|
+
['legend', 'labels', 'grid'].forEach(name => {
|
|
55
|
+
const value = input.components?.[name];
|
|
56
|
+
if (value !== undefined && !preferenceTriStates.includes(value)) errors.push(diagnostic('INVALID_PREFERENCE_COMPONENT', `components.${name}`, `${name} must be auto, true, or false.`, preferenceTriStates));
|
|
57
|
+
});
|
|
58
|
+
if (input.branding?.enabled !== undefined && !preferenceTriStates.includes(input.branding.enabled)) errors.push(diagnostic('INVALID_PREFERENCE_BRANDING', 'branding.enabled', 'branding.enabled must be auto, true, or false.', preferenceTriStates));
|
|
59
|
+
if (input.motion !== undefined && !preferenceMotions.includes(input.motion)) errors.push(diagnostic('INVALID_PREFERENCE_MOTION', 'motion', `Unsupported motion preference: ${input.motion}.`, preferenceMotions));
|
|
60
|
+
if (!partial && input.version !== undefined && input.version !== preferenceVersion) errors.push(diagnostic('UNSUPPORTED_PREFERENCE_VERSION', 'version', `Unsupported preference version: ${input.version}.`, preferenceVersion));
|
|
61
|
+
return { valid: errors.length === 0, errors, value: normalizePreferences(input, { partial }) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizePreferences(input = {}, { partial = false } = {}) {
|
|
65
|
+
const source = isPlainObject(input) ? input : {};
|
|
66
|
+
const normalized = partial ? {} : clone(defaultPreferences);
|
|
67
|
+
if (source.theme && isPlainObject(source.theme)) {
|
|
68
|
+
normalized.theme = merge(normalized.theme || {}, {
|
|
69
|
+
mode: source.theme.mode === undefined ? (partial ? undefined : null) : source.theme.mode,
|
|
70
|
+
preset: source.theme.preset === undefined ? (partial ? undefined : null) : source.theme.preset,
|
|
71
|
+
palette: source.theme.palette === undefined ? (partial ? undefined : null) : source.theme.palette
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (source.typography && isPlainObject(source.typography) && source.typography.scale !== undefined) normalized.typography = merge(normalized.typography || {}, { scale: Number(source.typography.scale) });
|
|
75
|
+
if (source.density !== undefined) normalized.density = source.density;
|
|
76
|
+
if (source.components && isPlainObject(source.components)) normalized.components = merge(normalized.components || {}, Object.fromEntries(['legend', 'labels', 'grid'].filter(name => source.components[name] !== undefined).map(name => [name, source.components[name]])));
|
|
77
|
+
if (source.branding && isPlainObject(source.branding) && source.branding.enabled !== undefined) normalized.branding = merge(normalized.branding || {}, { enabled: source.branding.enabled });
|
|
78
|
+
if (source.motion !== undefined) normalized.motion = source.motion;
|
|
79
|
+
normalized.version = preferenceVersion;
|
|
80
|
+
return normalized;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function mergePreferences(...values) {
|
|
84
|
+
return normalizePreferences(values.reduce((result, value) => merge(result, normalizePreferences(value, { partial: true })), {}));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveStorage(storage) {
|
|
88
|
+
if (storage && typeof storage.getItem === 'function' && typeof storage.setItem === 'function') return storage;
|
|
89
|
+
if (storage === 'localStorage' && typeof globalThis !== 'undefined' && globalThis.localStorage) return globalThis.localStorage;
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readState(storage, key) {
|
|
94
|
+
if (!storage) return null;
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(storage.getItem(key) || 'null');
|
|
97
|
+
if (!isPlainObject(parsed)) return null;
|
|
98
|
+
return { version: preferenceVersion, global: normalizePreferences(parsed.global), charts: isPlainObject(parsed.charts) ? Object.fromEntries(Object.entries(parsed.charts).map(([id, value]) => [id, normalizePreferences(value, { partial: true })])) : {} };
|
|
99
|
+
} catch { return null; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createPreferencesStore(options = {}) {
|
|
103
|
+
const storage = resolveStorage(options.storage);
|
|
104
|
+
const storageKey = options.storageKey || 'ichartjs:preferences:v1';
|
|
105
|
+
const loaded = readState(storage, storageKey);
|
|
106
|
+
let state = loaded || {
|
|
107
|
+
version: preferenceVersion,
|
|
108
|
+
global: normalizePreferences(options.global || {}),
|
|
109
|
+
charts: Object.fromEntries(Object.entries(options.charts || {}).map(([id, value]) => [id, normalizePreferences(value, { partial: true })]))
|
|
110
|
+
};
|
|
111
|
+
const listeners = new Set();
|
|
112
|
+
const persist = () => {
|
|
113
|
+
if (!storage) return false;
|
|
114
|
+
try { storage.setItem(storageKey, JSON.stringify(state)); return true; } catch { return false; }
|
|
115
|
+
};
|
|
116
|
+
const snapshot = () => clone(state);
|
|
117
|
+
const notify = (scope, chartId, source, persisted) => {
|
|
118
|
+
const payload = { type: 'change', scope, chartId: chartId || null, source: source || 'user', persisted, preferences: chartId ? getEffective(chartId) : clone(state.global), state: snapshot() };
|
|
119
|
+
listeners.forEach(listener => listener(payload));
|
|
120
|
+
};
|
|
121
|
+
const getGlobal = () => clone(state.global);
|
|
122
|
+
const getChart = chartId => clone(state.charts[String(chartId)] || {});
|
|
123
|
+
const getEffective = chartId => mergePreferences(defaultPreferences, state.global, chartId == null ? {} : state.charts[String(chartId)] || {});
|
|
124
|
+
const update = (scope, chartId, patch, { source = 'user', persist: shouldPersist = true } = {}) => {
|
|
125
|
+
const checked = validatePreferences(patch, { partial: true });
|
|
126
|
+
if (!checked.valid) { const error = new Error(checked.errors.map(item => item.message).join(' ')); error.code = 'INVALID_PREFERENCES'; error.details = checked.errors; throw error; }
|
|
127
|
+
if (scope === 'global') state.global = mergePreferences(state.global, patch);
|
|
128
|
+
else {
|
|
129
|
+
const key = String(chartId || 'default');
|
|
130
|
+
state.charts[key] = merge(state.charts[key] || {}, checked.value);
|
|
131
|
+
}
|
|
132
|
+
const persisted = shouldPersist ? persist() : false;
|
|
133
|
+
notify(scope, chartId, source, persisted);
|
|
134
|
+
return scope === 'global' ? getGlobal() : getEffective(chartId);
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
version: preferenceVersion,
|
|
138
|
+
storageKey,
|
|
139
|
+
getState: snapshot,
|
|
140
|
+
getGlobal,
|
|
141
|
+
getChart,
|
|
142
|
+
getEffective,
|
|
143
|
+
setGlobal(patch, options = {}) { return update('global', null, patch, options); },
|
|
144
|
+
setChart(chartId, patch, options = {}) { return update('chart', chartId, patch, options); },
|
|
145
|
+
reset({ scope = 'all', chartId, persist: shouldPersist = true, source = 'user' } = {}) {
|
|
146
|
+
if (scope === 'all' || scope === 'global') state.global = normalizePreferences({});
|
|
147
|
+
if (scope === 'all') state.charts = {};
|
|
148
|
+
if (scope === 'chart' && chartId != null) delete state.charts[String(chartId)];
|
|
149
|
+
const persisted = shouldPersist ? persist() : false;
|
|
150
|
+
notify(scope, chartId, source, persisted);
|
|
151
|
+
return chartId != null ? getEffective(chartId) : getGlobal();
|
|
152
|
+
},
|
|
153
|
+
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
154
|
+
persist,
|
|
155
|
+
storage: storage ? 'localStorage' : 'memory'
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function mergeThemePreference(theme, preferenceTheme = {}) {
|
|
160
|
+
const patch = Object.fromEntries(['mode', 'preset', 'palette'].filter(key => preferenceTheme?.[key] != null).map(key => [key, preferenceTheme[key]]));
|
|
161
|
+
if (!Object.keys(patch).length) return theme;
|
|
162
|
+
if (typeof theme === 'string') {
|
|
163
|
+
const base = themeModes.includes(theme) ? { mode: theme } : themePresets.includes(theme) ? { preset: theme } : {};
|
|
164
|
+
return { ...base, ...patch };
|
|
165
|
+
}
|
|
166
|
+
return { ...(isPlainObject(theme) ? theme : {}), ...patch };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function scaledFont(font, scale) {
|
|
170
|
+
return String(font || '').replace(/(\d+(?:\.\d+)?)px/, (_, value) => `${Number((Number(value) * scale).toFixed(2))}px`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function applyPreferencesToSpec(spec, preferences = {}) {
|
|
174
|
+
const result = clone(spec) || {};
|
|
175
|
+
const prefs = mergePreferences(preferences);
|
|
176
|
+
result.theme = clone(result.theme) || {};
|
|
177
|
+
const scale = prefs.typography.scale;
|
|
178
|
+
if (scale !== 1 && result.theme.typography) {
|
|
179
|
+
result.theme.typography = Object.fromEntries(Object.entries(result.theme.typography).map(([key, value]) => [key, { ...value, size: Number((Number(value.size) * scale).toFixed(2)), font: scaledFont(value.font, scale) }]));
|
|
180
|
+
}
|
|
181
|
+
if (prefs.density !== 'comfortable') {
|
|
182
|
+
const factor = prefs.density === 'compact' ? 0.82 : 1.2;
|
|
183
|
+
const padding = result.theme.layout?.padding || result.padding;
|
|
184
|
+
if (padding) result.padding = Object.fromEntries(Object.entries(padding).map(([key, value]) => [key, Math.max(0, Math.round(Number(value) * factor))]));
|
|
185
|
+
if (result.theme.layout) result.theme.layout = { ...result.theme.layout, density: prefs.density, spacing: Number((Number(result.theme.layout.spacing || 8) * factor).toFixed(2)) };
|
|
186
|
+
}
|
|
187
|
+
const component = (path, value) => value === 'auto' ? undefined : value;
|
|
188
|
+
const legend = component('legend', prefs.components.legend), labels = component('labels', prefs.components.labels), grid = component('grid', prefs.components.grid);
|
|
189
|
+
if (legend !== undefined) result.legend = { ...(result.legend || {}), visible: legend };
|
|
190
|
+
if (labels !== undefined) result.labels = { ...(result.labels || {}), enabled: labels };
|
|
191
|
+
if (grid !== undefined) result.grid = { ...(result.grid || {}), visible: grid };
|
|
192
|
+
if (prefs.branding.enabled !== 'auto') result.branding = { ...(result.branding || {}), enabled: prefs.branding.enabled };
|
|
193
|
+
return result;
|
|
194
|
+
}
|
package/src/project.mjs
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
* Project-management and process visualization scene builders.
|
|
3
3
|
* Covers Gantt, timeline, milestone, burndown, flow, and swimlane views.
|
|
4
4
|
*/
|
|
5
|
-
import { Scene } from './scene.mjs';
|
|
5
|
+
import { Scene, cubicBezierPoint } from './scene.mjs';
|
|
6
6
|
import { normalizeData } from './data.mjs';
|
|
7
|
-
import { layoutDiagram, normalizeDiagramData,
|
|
7
|
+
import { layoutDiagram, normalizeDiagramData, routeEdgePath } from './diagram.mjs';
|
|
8
8
|
import { analyzeBurndownSeries, analyzeSchedule } from './project-analytics.mjs';
|
|
9
9
|
import { createLinkedProjectState, filterProjectRows, linkedRecordId } from './project-linking.mjs';
|
|
10
10
|
import { contrastRatio, resolveTheme } from './theme.mjs';
|
|
11
|
+
import { axisLabelLayout, titleLayout } from './layout.mjs';
|
|
11
12
|
|
|
12
|
-
export const projectTypes = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane'];
|
|
13
|
+
export const projectTypes = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'];
|
|
13
14
|
const day = 86400000;
|
|
14
15
|
export const timestamp = value => value == null || value === '' ? NaN : new Date(value).getTime();
|
|
15
16
|
const dateLabel = value => new Date(value).toISOString().slice(0, 10);
|
|
@@ -20,21 +21,22 @@ function text(scene, id, content, x, y, style = {}) {
|
|
|
20
21
|
scene.add({ id, type: 'text', geometry: { text: String(content), x, y }, style: { fill: scene.theme?.text || '#334155', font: scene.theme?.typography?.label?.font || '12px system-ui', ...style }, zIndex: 4 });
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
function arrow(scene, id, points, reference, critical = false) {
|
|
24
|
+
function arrow(scene, id, points, reference, critical = false, curve = null) {
|
|
24
25
|
const color = critical ? scene.theme?.status?.danger || '#dc2626' : scene.theme?.axis || '#64748b';
|
|
25
26
|
const tip = points.at(-1), previous = points.at(-2);
|
|
26
27
|
const angle = Math.atan2(tip.y - previous.y, tip.x - previous.x), size = 7;
|
|
27
|
-
scene.add({ id, type: 'path', geometry: { points }, style: { fill: 'none', stroke: color, strokeWidth: critical ? 2.5 : 1.5 }, dataRef: reference, zIndex: 1 });
|
|
28
|
+
scene.add({ id, type: 'path', geometry: { points, ...(curve ? { curve } : {}) }, style: { fill: 'none', stroke: color, strokeWidth: critical ? 2.5 : 1.5 }, dataRef: reference, interactive: Boolean(reference?.edgeId), hitTolerance: 8, zIndex: 1 });
|
|
28
29
|
scene.add({ id: id.startsWith('dependency-') ? id.replace('dependency-', 'dependency-arrow-') : `${id}-arrow`, type: 'path', geometry: { points: [tip, { x: tip.x - size * Math.cos(angle - Math.PI / 6), y: tip.y - size * Math.sin(angle - Math.PI / 6) }, { x: tip.x - size * Math.cos(angle + Math.PI / 6), y: tip.y - size * Math.sin(angle + Math.PI / 6) }, tip] }, style: { fill: color, stroke: color }, zIndex: 3 });
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
function timeAxis(scene, plot, min, max) {
|
|
32
33
|
const map = value => plot.x + (timestamp(value) - min) / (max - min || day) * plot.width;
|
|
33
34
|
const ticks = Math.max(2, Math.min(5, Math.floor(plot.width / 100)));
|
|
35
|
+
const labelLayout = scene.xLabelLayout || axisLabelLayout({ theme: scene.theme }, Array(ticks).fill('2000-00-00'), plot.width);
|
|
34
36
|
for (let index = 0; index < ticks; index += 1) {
|
|
35
37
|
const time = min + (max - min) * index / (ticks - 1), x = map(time);
|
|
36
38
|
scene.add({ id: `project-grid-${index}`, type: 'line', geometry: { x1: x, y1: plot.y, x2: x, y2: plot.y + plot.height }, style: { stroke: scene.theme?.grid || '#e2e8f0' } });
|
|
37
|
-
text(scene, `project-tick-${index}`, dateLabel(time), x, plot.y + plot.height + 22, { fill: scene.theme?.muted, textAnchor: index === 0 ? 'start' : index === ticks - 1 ? 'end' : 'middle', font: scene.theme?.typography?.axis?.font || '11px system-ui' });
|
|
39
|
+
text(scene, `project-tick-${index}`, dateLabel(time), x, plot.y + plot.height + (labelLayout.rotation ? 10 : 22), { fill: scene.theme?.muted, textAnchor: labelLayout.rotation ? 'end' : index === 0 ? 'start' : index === ticks - 1 ? 'end' : 'middle', textBaseline: labelLayout.rotation ? 'middle' : 'alphabetic', rotation: labelLayout.rotation, font: scene.theme?.typography?.axis?.font || '11px system-ui' });
|
|
38
40
|
}
|
|
39
41
|
return map;
|
|
40
42
|
}
|
|
@@ -216,7 +218,7 @@ function burndownScene(scene, spec, rows, state) {
|
|
|
216
218
|
|
|
217
219
|
function diagramScene(scene, spec, rows, state) {
|
|
218
220
|
const diagramSpec = normalizeDiagramData(spec);
|
|
219
|
-
const plot = state.plot, edges = diagramSpec.edges, lanes = diagramSpec.lanes, groups = diagramSpec.groups, layout = layoutDiagram(diagramSpec, plot);
|
|
221
|
+
const plot = state.plot, mode = diagramSpec.diagram.mode, edges = diagramSpec.edges, lanes = diagramSpec.lanes, layers = diagramSpec.layers, groups = diagramSpec.groups, layout = layoutDiagram(diagramSpec, plot);
|
|
220
222
|
rows = diagramSpec.nodes;
|
|
221
223
|
const collapsedGroups = new Set(groups.filter(group => group.collapsed).map(group => group.id));
|
|
222
224
|
const ranks = new Map(rows.map(row => [row.id, 0]));
|
|
@@ -235,17 +237,31 @@ function diagramScene(scene, spec, rows, state) {
|
|
|
235
237
|
scene.add({ id: `lane-${index}`, type: 'rect', geometry: { x: plot.x, y: plot.y + index * laneHeight, width: Math.max(plot.width, columns * gapX), height: laneHeight }, style: { fill: index % 2 ? spec.theme.surface : spec.theme.background, stroke: spec.theme.border }, zIndex: -1 });
|
|
236
238
|
text(scene, `lane-label-${index}`, lane.label || lane.id, plot.x - 12, plot.y + index * laneHeight + 20, { textAnchor: 'end' });
|
|
237
239
|
});
|
|
240
|
+
const layerSlots = new Map();
|
|
241
|
+
if (mode === 'architecture' && layers.length) layers.forEach(layer => layerSlots.set(layer.id, 0));
|
|
242
|
+
if (mode === 'architecture' && layers.length) layers.forEach((layer, index) => {
|
|
243
|
+
const layerHeight = plot.height / layers.length;
|
|
244
|
+
scene.add({ id: `layer-${layer.id}`, type: 'rect', geometry: { x: plot.x, y: plot.y + index * layerHeight, width: plot.width, height: layerHeight }, style: { fill: index % 2 ? spec.theme.surface : spec.theme.background, stroke: spec.theme.border }, zIndex: -2 });
|
|
245
|
+
text(scene, `layer-label-${layer.id}`, layer.label || layer.id, plot.x - 12, plot.y + index * layerHeight + 20, { textAnchor: 'end', font: spec.theme.typography.legend.font });
|
|
246
|
+
});
|
|
238
247
|
rows.forEach((row, index) => {
|
|
239
248
|
const rank = ranks.get(row.id), lane = Math.max(0, lanes.findIndex(item => item.id === row.laneId));
|
|
240
249
|
const slotKey = `${lane}:${rank}`, slot = slots.get(slotKey) || 0;
|
|
241
250
|
slots.set(slotKey, slot + 1);
|
|
242
251
|
const sameCell = rows.filter(item => ranks.get(item.id) === rank && (!lanes.length || item.laneId === row.laneId)).length;
|
|
243
252
|
const generated = layout[row.id];
|
|
244
|
-
const
|
|
253
|
+
const layerIndex = mode === 'architecture' && layers.length ? Math.max(0, layers.findIndex(layer => layer.id === row.layerId)) : -1;
|
|
254
|
+
const layerHeight = layers.length ? plot.height / layers.length : plot.height;
|
|
255
|
+
const layerSlot = layerIndex >= 0 ? layerSlots.get(row.layerId) : 0;
|
|
256
|
+
if (layerIndex >= 0) layerSlots.set(row.layerId, layerSlot + 1);
|
|
257
|
+
const layerCount = layerIndex >= 0 ? rows.filter(item => item.layerId === row.layerId).length : 1;
|
|
258
|
+
const geometry = { x: row.position?.x ?? (layerIndex >= 0 ? plot.x + (layerIndex + 0.5) * plot.width / layers.length - 56 : generated?.x ?? plot.x + rank * gapX + 12), y: row.position?.y ?? (layerIndex >= 0 ? plot.y + layerIndex * layerHeight + (layerSlot + 0.5) * layerHeight / Math.max(1, layerCount) - 18 : generated?.y ?? plot.y + (lanes.length ? lane * laneHeight : 0) + (slot + 0.5) * (lanes.length ? laneHeight : Math.max(plot.height, sameCell * 64)) / sameCell - 18), width: row.size?.width || 112, height: row.size?.height || 36 };
|
|
245
259
|
positions.set(row.id, geometry);
|
|
246
260
|
if (collapsedGroups.has(row.groupId)) return;
|
|
247
|
-
|
|
248
|
-
|
|
261
|
+
const depth = row.parentId ? (ranks.get(row.id) || 0) : 0;
|
|
262
|
+
const colorIndex = mode === 'mindmap' ? depth % spec.colors.length : lane >= 0 ? lane % spec.colors.length : index % spec.colors.length;
|
|
263
|
+
scene.add({ id: `node-${row.id}`, type: 'rect', geometry, bounds: { ...geometry }, style: { fill: spec.colors[colorIndex], stroke: row.root || mode === 'mindmap' && !row.parentId ? spec.theme.focus : spec.theme.background, strokeWidth: row.root || mode === 'mindmap' && !row.parentId ? 2 : 1 }, dataRef: { nodeId: row.id, dataIndex: index, datum: row, groupId: row.groupId || null, layerId: row.layerId || null, parentId: row.parentId || null }, interactive: true, zIndex: 2 });
|
|
264
|
+
if (spec.editing?.enabled && spec.interaction?.portConnect) (row.ports || []).forEach(port => {
|
|
249
265
|
const point = port.side === 'left' ? { x: geometry.x, y: geometry.y + geometry.height * (port.offset ?? 0.5) } : port.side === 'top' ? { x: geometry.x + geometry.width * (port.offset ?? 0.5), y: geometry.y } : port.side === 'bottom' ? { x: geometry.x + geometry.width * (port.offset ?? 0.5), y: geometry.y + geometry.height } : { x: geometry.x + geometry.width, y: geometry.y + geometry.height * (port.offset ?? 0.5) };
|
|
250
266
|
scene.add({ id: `port-${row.id}-${port.id}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 6, y: point.y - 6, width: 12, height: 12 }, style: { fill: spec.theme.background, stroke: spec.theme.text, strokeWidth: 1.5 }, dataRef: { nodeId: row.id, portId: port.id, groupId: row.groupId || null }, interactive: true, zIndex: 4 });
|
|
251
267
|
});
|
|
@@ -264,6 +280,15 @@ function diagramScene(scene, spec, rows, state) {
|
|
|
264
280
|
const label = scene.find(`group-label-${group.id}`);
|
|
265
281
|
if (label) { label.bounds = { x: left, y: top, width: right - left, height: 20 }; label.dataRef = { groupId: group.id, collapsed: Boolean(group.collapsed) }; label.interactive = true; }
|
|
266
282
|
});
|
|
283
|
+
if (mode === 'architecture') {
|
|
284
|
+
diagramSpec.boundaries.forEach((boundary, boundaryIndex) => {
|
|
285
|
+
const boxes = rows.filter(row => (boundary.nodeIds || []).includes(row.id)).map(row => positions.get(row.id)).filter(Boolean);
|
|
286
|
+
if (!boxes.length) return;
|
|
287
|
+
const padding = Number(boundary.padding) || 16, left = Math.min(...boxes.map(box => box.x)) - padding, top = Math.min(...boxes.map(box => box.y)) - padding, right = Math.max(...boxes.map(box => box.x + box.width)) + padding, bottom = Math.max(...boxes.map(box => box.y + box.height)) + padding;
|
|
288
|
+
scene.add({ id: `boundary-${boundary.id || boundaryIndex}`, type: 'rect', geometry: { x: left, y: top, width: right - left, height: bottom - top }, bounds: { x: left, y: top, width: right - left, height: bottom - top }, style: { fill: 'none', stroke: boundary.color || spec.theme.focus, strokeWidth: 1.5, opacity: 0.8 }, dataRef: { boundaryId: boundary.id || `boundary-${boundaryIndex}` }, interactive: false, zIndex: -1 });
|
|
289
|
+
text(scene, `boundary-label-${boundary.id || boundaryIndex}`, boundary.label || boundary.id || `Boundary ${boundaryIndex + 1}`, left + 8, top + 15, { font: spec.theme.typography.legend.font, fill: boundary.color || spec.theme.focus });
|
|
290
|
+
});
|
|
291
|
+
}
|
|
267
292
|
edges.forEach((edge, index) => {
|
|
268
293
|
const fromNode = rows.find(row => row.id === edge.from), toNode = rows.find(row => row.id === edge.to);
|
|
269
294
|
if (!fromNode || !toNode) return;
|
|
@@ -272,10 +297,14 @@ function diagramScene(scene, spec, rows, state) {
|
|
|
272
297
|
const to = toNode.groupId && collapsedGroups.has(toNode.groupId) ? groupBoxes.get(toNode.groupId) : positions.get(edge.to);
|
|
273
298
|
if (!from || !to) return;
|
|
274
299
|
const obstacles = [...positions.entries()].filter(([id]) => id !== edge.from && id !== edge.to && !collapsedGroups.has(rows.find(row => row.id === id)?.groupId)).map(([, box]) => box);
|
|
275
|
-
const
|
|
276
|
-
|
|
300
|
+
const fromPortDefinition = fromNode.groupId && collapsedGroups.has(fromNode.groupId) ? null : fromNode?.ports?.find(port => port.id === edge.fromPort);
|
|
301
|
+
const toPortDefinition = toNode.groupId && collapsedGroups.has(toNode.groupId) ? null : toNode?.ports?.find(port => port.id === edge.toPort);
|
|
302
|
+
const routing = edge.routing || diagramSpec.diagram.routing;
|
|
303
|
+
const path = routeEdgePath({ ...edge, curveTension: edge.curveTension ?? diagramSpec.diagram.curveTension, grid: diagramSpec.diagram.grid, obstacles, fromPortDefinition, toPortDefinition }, from, to, routing);
|
|
304
|
+
const points = path.points;
|
|
305
|
+
arrow(scene, `edge-${index}`, points, { from: edge.from, to: edge.to, edgeId: edge.id || `edge-${index}`, status: edge.status, routing, curveTension: edge.curveTension ?? diagramSpec.diagram.curveTension, waypoints: edge.waypoints || [], fromPortDefinition, toPortDefinition, fromGroupId: fromNode.groupId || null, toGroupId: toNode.groupId || null }, edge.critical === true, path.curve);
|
|
277
306
|
if (edge.label) {
|
|
278
|
-
const middle = points[Math.floor(points.length / 2)];
|
|
307
|
+
const middle = path.curve === 'cubic' ? cubicBezierPoint(points, 0.5) : points[Math.floor(points.length / 2)];
|
|
279
308
|
text(scene, `edge-label-${index}`, edge.label, middle.x, middle.y - 8, { textAnchor: 'middle', font: spec.theme.typography.axis.font });
|
|
280
309
|
}
|
|
281
310
|
});
|
|
@@ -291,7 +320,7 @@ export function projectView(spec) {
|
|
|
291
320
|
export function buildProjectScene(spec) {
|
|
292
321
|
const theme = spec.theme && typeof spec.theme === 'object' && spec.theme.typography ? spec.theme : resolveTheme(spec.theme, spec);
|
|
293
322
|
spec = { ...spec, theme, colors: spec.colors || theme.colors, background: spec.background || theme.background, padding: spec.padding || theme.layout.padding };
|
|
294
|
-
const diagram = ['flow', 'swimlane'].includes(spec.type);
|
|
323
|
+
const diagram = ['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type);
|
|
295
324
|
const rawRows = diagram ? spec.nodes ?? spec.data.nodes ?? [] : spec.data.values;
|
|
296
325
|
const sourceRows = rawRows.map(row => ({ ...row }));
|
|
297
326
|
const linked = createLinkedProjectState(sourceRows, projectConfig(spec).linked || {});
|
|
@@ -299,9 +328,13 @@ export function buildProjectScene(spec) {
|
|
|
299
328
|
const data = { ...normalizeData(visibleRows), rows: visibleRows.map(row => ({ ...row })), sourceRows: sourceRows.map(row => ({ ...row })) };
|
|
300
329
|
const scene = new Scene(spec.width, spec.height);
|
|
301
330
|
scene.theme = spec.theme;
|
|
302
|
-
const left = ['gantt', 'timeline', 'milestone', 'swimlane'].includes(spec.type) ? Math.min(
|
|
303
|
-
const
|
|
304
|
-
const
|
|
331
|
+
const left = ['gantt', 'timeline', 'milestone', 'swimlane', 'architecture'].includes(spec.type) ? Math.min(170, spec.width * 0.34) : spec.padding.left;
|
|
332
|
+
const title = titleLayout(spec);
|
|
333
|
+
const plotTop = Math.max(spec.padding.top, title.bottom + (title.bottom ? 12 : 0)) + (spec.type === 'burndown' ? 16 : 0);
|
|
334
|
+
const plotWidth = Math.max(1, spec.width - left - spec.padding.right), xLabels = axisLabelLayout(spec, Array(Math.max(2, Math.min(5, Math.floor(plotWidth / 100)))).fill('2000-00-00'), plotWidth);
|
|
335
|
+
const bottomReserve = Math.max(spec.padding.bottom + 16, Math.ceil(16 + xLabels.projectedHeight));
|
|
336
|
+
const state = { plot: { x: left, y: plotTop, width: plotWidth, height: Math.max(1, spec.height - plotTop - bottomReserve) }, xLabels, compact: spec.width < 360 || spec.height < 240, recommendedSize: { minWidth: 280, minHeight: 220 }, linked, projectAnalytics: { linked } };
|
|
337
|
+
scene.xLabelLayout = xLabels;
|
|
305
338
|
if (spec.type === 'gantt') {
|
|
306
339
|
state.schedule = analyzeSchedule(data.rows, { calendar: projectConfig(spec).calendar || {} });
|
|
307
340
|
state.projectAnalytics.schedule = state.schedule;
|
|
@@ -321,8 +354,8 @@ export function buildProjectScene(spec) {
|
|
|
321
354
|
if (geometry.points) geometry.points = geometry.points.map(point => ({ x: mapX(point.x), y: mapY(point.y) }));
|
|
322
355
|
if (node.bounds) node.bounds = { x: mapX(node.bounds.x), y: mapY(node.bounds.y), width: node.bounds.width * view.scale, height: node.bounds.height * view.scale };
|
|
323
356
|
});
|
|
324
|
-
if (spec.title?.text) text(scene, 'title', spec.title.text, spec.width / 2,
|
|
325
|
-
if (spec.title?.subtitle) text(scene, 'subtitle', spec.title.subtitle, spec.width / 2,
|
|
357
|
+
if (spec.title?.text) text(scene, 'title', spec.title.text, spec.width / 2, title.titleY, { fill: spec.theme.text, font: spec.theme.typography.title.font, textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' });
|
|
358
|
+
if (spec.title?.subtitle) text(scene, 'subtitle', spec.title.subtitle, spec.width / 2, title.subtitleY, { fill: spec.theme.muted, font: spec.theme.typography.subtitle.font, textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' });
|
|
326
359
|
state.view = view;
|
|
327
360
|
return { scene, data, state };
|
|
328
361
|
}
|
|
@@ -339,6 +372,8 @@ export function projectTooltip(type, row) {
|
|
|
339
372
|
].filter(Boolean).join('\n');
|
|
340
373
|
const dependencies = (row.dependencies || []).map(dependencyId).filter(Boolean);
|
|
341
374
|
const variance = row.endVarianceDays ?? row.baselineVarianceDays ?? null;
|
|
375
|
+
if (type === 'mindmap') return [row.label || row.id, row.parentId ? `Parent: ${row.parentId}` : 'Root topic', row.branch ? `Branch: ${row.branch}` : null, row.description].filter(value => value != null && value !== '').join('\n');
|
|
376
|
+
if (type === 'architecture') return [row.label || row.id, row.layerId ? `Layer: ${row.layerId}` : null, row.boundaryId ? `Boundary: ${row.boundaryId}` : null, row.role ? `Role: ${row.role}` : null, row.description].filter(value => value != null && value !== '').join('\n');
|
|
342
377
|
return [
|
|
343
378
|
row.name || row.title || row.label || row.id,
|
|
344
379
|
row.start ? `${row.start} → ${row.end || row.start}` : row.date,
|
|
@@ -354,16 +389,21 @@ export function projectTooltip(type, row) {
|
|
|
354
389
|
}
|
|
355
390
|
|
|
356
391
|
export function rerouteDiagramScene(scene) {
|
|
392
|
+
const nodeGeometries = new Map();
|
|
393
|
+
scene.walk(item => { if (item.id.startsWith('node-') && item.dataRef?.nodeId && item.geometry) nodeGeometries.set(item.dataRef.nodeId, item.geometry); });
|
|
357
394
|
scene.walk(node => {
|
|
358
395
|
if (!node.id.startsWith('edge-') || !node.dataRef?.from || !node.dataRef?.to) return;
|
|
359
|
-
const source =
|
|
396
|
+
const source = nodeGeometries.get(node.dataRef.from), destination = nodeGeometries.get(node.dataRef.to);
|
|
360
397
|
if (!source || !destination) return;
|
|
361
|
-
const
|
|
362
|
-
const
|
|
363
|
-
node.geometry.points =
|
|
398
|
+
const obstacles = [...nodeGeometries.entries()].filter(([id]) => id !== node.dataRef.from && id !== node.dataRef.to).map(([, geometry]) => geometry);
|
|
399
|
+
const path = routeEdgePath({ ...node.dataRef, obstacles, grid: 8 }, source, destination, node.dataRef.routing || 'orthogonal');
|
|
400
|
+
node.geometry.points = path.points;
|
|
401
|
+
if (path.curve) node.geometry.curve = path.curve;
|
|
402
|
+
else delete node.geometry.curve;
|
|
403
|
+
const end = node.geometry.points.at(-1);
|
|
364
404
|
const arrowNode = scene.find(`${node.id}-arrow`), beforeTip = node.geometry.points.at(-2);
|
|
365
405
|
if (arrowNode) arrowNode.geometry.points = [end, { x: end.x - 7 * Math.cos(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) - Math.PI / 6), y: end.y - 7 * Math.sin(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) - Math.PI / 6) }, { x: end.x - 7 * Math.cos(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) + Math.PI / 6), y: end.y - 7 * Math.sin(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) + Math.PI / 6) }, end];
|
|
366
|
-
const edgeIndex = Number(node.id.slice(5)), label = scene.find(`edge-label-${edgeIndex}`), labelPoint = node.geometry.points[Math.floor(node.geometry.points.length / 2)];
|
|
406
|
+
const edgeIndex = Number(node.id.slice(5)), label = scene.find(`edge-label-${edgeIndex}`), labelPoint = node.geometry.curve === 'cubic' ? cubicBezierPoint(node.geometry.points, 0.5) : node.geometry.points[Math.floor(node.geometry.points.length / 2)];
|
|
367
407
|
if (label && labelPoint) { label.geometry.x = labelPoint.x; label.geometry.y = labelPoint.y - 8; }
|
|
368
408
|
});
|
|
369
409
|
return scene;
|
package/src/renderer.mjs
CHANGED
|
@@ -31,7 +31,7 @@ export class CanvasRenderer extends BaseRenderer {
|
|
|
31
31
|
mount(container) { super.mount(container); this.canvas = this.container.tagName === 'CANVAS' ? this.container : document.createElement('canvas'); if (this.canvas !== this.container) { this.container.innerHTML = ''; this.container.appendChild(this.canvas); } this.canvas.style.background = this.options.background || '#ffffff'; this.ctx = this.canvas.getContext('2d'); return this; }
|
|
32
32
|
resize(width, height, pixelRatio = globalThis.devicePixelRatio || 1) { this.width = width; this.height = height; this.pixelRatio = pixelRatio; this.canvas.width = Math.round(width * pixelRatio); this.canvas.height = Math.round(height * pixelRatio); if (this.canvas.style) { this.canvas.style.width = `${width}px`; this.canvas.style.height = `${height}px`; } this.ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); }
|
|
33
33
|
clear() { if (this.ctx) this.ctx.clearRect(0, 0, this.width, this.height); }
|
|
34
|
-
|
|
34
|
+
render(scene) { this.clear(); const ctx = this.ctx; if (hasFill({ fill: this.options?.background })) { ctx.save(); ctx.fillStyle = this.options.background; ctx.fillRect(0, 0, this.width, this.height); ctx.restore(); } const draw = node => { if (!node.visible) return; const g = node.geometry || {}; const s = node.style || {}; ctx.save(); ctx.globalAlpha = s.opacity == null ? 1 : s.opacity; ctx.fillStyle = hasFill(s) ? color(s, 'fill', node.highlighted || node.selected ? '#1d4ed8' : '#2563eb') : 'rgba(0,0,0,0)'; ctx.strokeStyle = hasStroke(s) ? color(s, 'stroke', '#0f172a') : 'rgba(0,0,0,0)'; ctx.lineWidth = s.strokeWidth || 1; if (node.type === 'rect') { if (hasFill(s)) ctx.fillRect(g.x, g.y, g.width, g.height); if (hasStroke(s)) ctx.strokeRect(g.x, g.y, g.width, g.height); } else if (node.type === 'line') { ctx.beginPath(); ctx.moveTo(g.x1, g.y1); ctx.lineTo(g.x2, g.y2); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'path') { ctx.beginPath(); if (g.curve === 'cubic' && g.points?.length === 4) { ctx.moveTo(g.points[0].x, g.points[0].y); ctx.bezierCurveTo(g.points[1].x, g.points[1].y, g.points[2].x, g.points[2].y, g.points[3].x, g.points[3].y); } else g.points.forEach((point, index) => index ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y)); if (g.closed) ctx.closePath(); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'circle') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, 0, Math.PI * 2); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'arc') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, g.start, g.end); if (g.innerR > 0) { ctx.arc(g.cx, g.cy, g.innerR, g.end, g.start, true); } else ctx.lineTo(g.cx, g.cy); ctx.closePath(); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'text') { ctx.fillStyle = hasFill(s) ? s.fill : '#0f172a'; ctx.font = s.font || '12px system-ui'; const align = s.textAlign || (s.textAnchor === 'end' ? 'end' : s.textAnchor === 'middle' || s.textAnchor === 'center' ? 'center' : s.textAnchor === 'start' ? 'start' : 'left'); ctx.textAlign = align; ctx.textBaseline = s.textBaseline || s.baseline || 'alphabetic'; if (Number.isFinite(Number(s.rotation)) && Number(s.rotation) !== 0) { ctx.translate(g.x, g.y); ctx.rotate(Number(s.rotation) * Math.PI / 180); ctx.fillText(g.text, 0, 0); } else ctx.fillText(g.text, g.x, g.y); } node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(draw); ctx.restore(); }; draw(scene.root); }
|
|
35
35
|
exportImage(type = 'image/png') { return this.canvas.toDataURL(type); }
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -86,6 +86,6 @@ export class SVGRenderer extends BaseRenderer {
|
|
|
86
86
|
this.svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
|
87
87
|
this.width = w; this.height = h;
|
|
88
88
|
}
|
|
89
|
-
this.clear(); const renderNode = node => { if (!node.visible || node.type === 'root') { node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); return; } const g = node.geometry || {}, s = node.style || {}; const element = document.createElementNS('http://www.w3.org/2000/svg', node.type === 'path' || node.type === 'arc' ? 'path' : node.type === 'text' ? 'text' : node.type); element.setAttribute('id', node.id); element.setAttribute('data-node-id', node.id); if (node.dataRef) element.dataset.dataRef = JSON.stringify(node.dataRef); if (node.type === 'rect') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); element.setAttribute('width', g.width); element.setAttribute('height', g.height); } else if (node.type === 'line') { ['x1', 'y1', 'x2', 'y2'].forEach(key => element.setAttribute(key, g[key])); } else if (node.type === 'circle') { element.setAttribute('cx', g.cx); element.setAttribute('cy', g.cy); element.setAttribute('r', g.r); } else if (node.type === 'arc') { const large = g.end - g.start > Math.PI ? 1 : 0, outerStart = `${g.cx + g.r * Math.cos(g.start)} ${g.cy + g.r * Math.sin(g.start)}`, outerEnd = `${g.cx + g.r * Math.cos(g.end)} ${g.cy + g.r * Math.sin(g.end)}`; const p = g.innerR > 0 ? [`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'] : [`M ${g.cx} ${g.cy}`, `L ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, 'Z']; element.setAttribute('d', p.join(' ')); } else if (node.type === 'path')
|
|
89
|
+
this.clear(); const renderNode = node => { if (!node.visible || node.type === 'root') { node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); return; } const g = node.geometry || {}, s = node.style || {}; const element = document.createElementNS('http://www.w3.org/2000/svg', node.type === 'path' || node.type === 'arc' ? 'path' : node.type === 'text' ? 'text' : node.type); element.setAttribute('id', node.id); element.setAttribute('data-node-id', node.id); if (node.dataRef) element.dataset.dataRef = JSON.stringify(node.dataRef); if (node.type === 'rect') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); element.setAttribute('width', g.width); element.setAttribute('height', g.height); } else if (node.type === 'line') { ['x1', 'y1', 'x2', 'y2'].forEach(key => element.setAttribute(key, g[key])); } else if (node.type === 'circle') { element.setAttribute('cx', g.cx); element.setAttribute('cy', g.cy); element.setAttribute('r', g.r); } else if (node.type === 'arc') { const large = g.end - g.start > Math.PI ? 1 : 0, outerStart = `${g.cx + g.r * Math.cos(g.start)} ${g.cy + g.r * Math.sin(g.start)}`, outerEnd = `${g.cx + g.r * Math.cos(g.end)} ${g.cy + g.r * Math.sin(g.end)}`; const p = g.innerR > 0 ? [`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'] : [`M ${g.cx} ${g.cy}`, `L ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, 'Z']; element.setAttribute('d', p.join(' ')); } else if (node.type === 'path') { const d = g.curve === 'cubic' && g.points?.length === 4 ? `M ${g.points[0].x} ${g.points[0].y} C ${g.points[1].x} ${g.points[1].y} ${g.points[2].x} ${g.points[2].y} ${g.points[3].x} ${g.points[3].y}` : `${g.points.map((p, i) => `${i ? 'L' : 'M'} ${p.x} ${p.y}`).join(' ')}${g.closed ? ' Z' : ''}`; element.setAttribute('d', d); } else if (node.type === 'text') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); if (Number.isFinite(Number(s.rotation)) && Number(s.rotation) !== 0) element.setAttribute('transform', `rotate(${Number(s.rotation)} ${g.x} ${g.y})`); element.textContent = g.text; const anchor = s.textAnchor || (s.textAlign === 'end' ? 'end' : s.textAlign === 'center' ? 'middle' : s.textAlign === 'right' ? 'end' : s.textAlign === 'left' ? 'start' : 'start'); if (anchor) element.setAttribute('text-anchor', anchor); const baseline = s.textBaseline || s.baseline || 'alphabetic'; if (baseline === 'top') { element.setAttribute('dominant-baseline', 'text-before-edge'); element.setAttribute('alignment-baseline', 'before-edge'); } else if (baseline === 'middle' || baseline === 'central') { element.setAttribute('dominant-baseline', 'middle'); element.setAttribute('alignment-baseline', 'middle'); } else if (baseline === 'bottom' || baseline === 'hanging') { element.setAttribute('dominant-baseline', 'text-after-edge'); element.setAttribute('alignment-baseline', 'after-edge'); } else { element.setAttribute('dominant-baseline', 'alphabetic'); element.setAttribute('alignment-baseline', 'alphabetic'); } if (s.font) { const raw = String(s.font).trim(); element.setAttribute('font', raw); const sizeMatch = raw.match(/\b(\d+(?:\.\d+)?)\s*(px|em|rem|pt|%)/i); const size = sizeMatch ? `${sizeMatch[1]}${sizeMatch[2].toLowerCase()}` : ''; if (size) element.style.fontSize = size; const weightMatch = raw.match(/^\s*(\d{3}|normal|bold|lighter|bolder)\b/i); if (weightMatch) element.style.fontWeight = weightMatch[1]; const family = raw.replace(/^\s*(?:(?:normal|italic|oblique)(?:\s+[^0-9\s]+)?\s+)?(?:\d{3}|normal|bold|lighter|bolder)\s+/i, '').replace(/\b\d+(?:\.\d+)?\s*(?:px|em|rem|pt|%)(?:\s*\/\s*\S+)?\s*/i, '').trim(); if (family) element.style.fontFamily = family; } } if (node.type === 'path' || node.type === 'circle' || node.type === 'rect' || node.type === 'arc') { element.setAttribute('fill', hasFill(s) ? s.fill : 'none'); } else if (node.type === 'text') { element.setAttribute('fill', hasFill(s) ? s.fill : '#0f172a'); } if (hasStroke(s)) element.setAttribute('stroke', s.stroke); else if (s.stroke != null && typeof s.stroke === 'string' && NO_FILL_TOKENS.has(s.stroke.trim().toLowerCase())) element.setAttribute('stroke', 'none'); if (s.strokeWidth != null) element.setAttribute('stroke-width', s.strokeWidth); if (s.opacity != null) element.setAttribute('opacity', node.highlighted || node.selected ? 1 : s.opacity); if (node.highlighted || node.selected) element.setAttribute('filter', 'brightness(1.2)'); if (node.interactive) element.style.cursor = 'pointer'; if (s.pointerEvents === 'none') element.style.pointerEvents = 'none'; if (s.ariaHidden === 'true') element.setAttribute('aria-hidden', 'true'); if (s.role === 'presentation') element.setAttribute('role', 'presentation'); if (node.decorative) { element.setAttribute('aria-hidden', 'true'); element.setAttribute('role', 'presentation'); element.style.pointerEvents = 'none'; element.style.userSelect = 'none'; } this.svg.appendChild(element); node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }; scene.root.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }
|
|
90
90
|
exportString() { if (!this.svg) return ''; return new XMLSerializer().serializeToString(this.svg); }
|
|
91
91
|
}
|
package/src/scene.mjs
CHANGED
|
@@ -9,10 +9,41 @@ export class SceneNode {
|
|
|
9
9
|
add(node) { this.children.push(node instanceof SceneNode ? node : new SceneNode(node)); return node; }
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export function pointToSegmentDistance(point, start, end) {
|
|
13
|
+
const dx = end.x - start.x, dy = end.y - start.y;
|
|
14
|
+
if (!dx && !dy) return Math.hypot(point.x - start.x, point.y - start.y);
|
|
15
|
+
const ratio = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy)));
|
|
16
|
+
return Math.hypot(point.x - (start.x + ratio * dx), point.y - (start.y + ratio * dy));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function cubicBezierPoint(points, t) {
|
|
20
|
+
if (!Array.isArray(points) || points.length !== 4) return points?.[0] || { x: 0, y: 0 };
|
|
21
|
+
const ratio = Math.max(0, Math.min(1, Number(t) || 0)), inverse = 1 - ratio;
|
|
22
|
+
return {
|
|
23
|
+
x: inverse ** 3 * points[0].x + 3 * inverse ** 2 * ratio * points[1].x + 3 * inverse * ratio ** 2 * points[2].x + ratio ** 3 * points[3].x,
|
|
24
|
+
y: inverse ** 3 * points[0].y + 3 * inverse ** 2 * ratio * points[1].y + 3 * inverse * ratio ** 2 * points[2].y + ratio ** 3 * points[3].y
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sampleCubicBezier(points, segments = 20) {
|
|
29
|
+
const count = Math.max(4, Math.round(Number(segments) || 20));
|
|
30
|
+
return Array.from({ length: count + 1 }, (_, index) => cubicBezierPoint(points, index / count));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function geometryHit(node, x, y) {
|
|
34
|
+
const geometry = node.geometry || {}, tolerance = Number(node.hitTolerance) || Math.max(6, Number(node.style?.strokeWidth) + 4 || 6);
|
|
35
|
+
if (node.type === 'line') return pointToSegmentDistance({ x, y }, { x: geometry.x1, y: geometry.y1 }, { x: geometry.x2, y: geometry.y2 }) <= tolerance;
|
|
36
|
+
if (node.type === 'path' && Array.isArray(geometry.points)) {
|
|
37
|
+
const points = geometry.curve === 'cubic' ? sampleCubicBezier(geometry.points) : geometry.points;
|
|
38
|
+
return points.some((point, index) => index > 0 && pointToSegmentDistance({ x, y }, points[index - 1], point) <= tolerance);
|
|
39
|
+
}
|
|
40
|
+
return Boolean(node.bounds && x >= node.bounds.x && x <= node.bounds.x + node.bounds.width && y >= node.bounds.y && y <= node.bounds.y + node.bounds.height);
|
|
41
|
+
}
|
|
42
|
+
|
|
12
43
|
export class Scene {
|
|
13
44
|
constructor(width, height) { this.width = width; this.height = height; this.root = new SceneNode({ id: 'root', type: 'group' }); }
|
|
14
45
|
add(node) { return this.root.add(node); }
|
|
15
46
|
walk(fn, node = this.root) { fn(node); node.children.forEach(child => this.walk(fn, child)); }
|
|
16
47
|
find(id) { let found; this.walk(node => { if (node.id === id) found = node; }); return found; }
|
|
17
|
-
hit(x, y) { const hits = []; this.walk(node => { if (node.visible && node.interactive && node
|
|
48
|
+
hit(x, y) { const hits = []; this.walk(node => { if (node.visible && node.interactive && geometryHit(node, x, y)) hits.push(node); }); return hits.sort((a, b) => b.zIndex - a.zIndex)[0] || null; }
|
|
18
49
|
}
|
package/src/schema.mjs
CHANGED
|
@@ -80,9 +80,14 @@ const models = {
|
|
|
80
80
|
toPort: { type: 'string', editable: true },
|
|
81
81
|
label: { type: 'string', editable: true },
|
|
82
82
|
status,
|
|
83
|
-
routing: { type: 'enum', values: ['straight', 'orthogonal', 'curved'], editable: true }
|
|
83
|
+
routing: { type: 'enum', values: ['straight', 'orthogonal', 'curved'], editable: true },
|
|
84
|
+
curveTension: { type: 'number', min: 0.2, max: 0.8, editable: true },
|
|
85
|
+
waypoints: { type: 'array', editable: true, items: { type: 'object', properties: { x: { type: 'number', required: true }, y: { type: 'number', required: true } } } }
|
|
84
86
|
}),
|
|
85
|
-
swimlane: schema('swimlane', { label: { type: 'string', required: true, editable: true } })
|
|
87
|
+
swimlane: schema('swimlane', { label: { type: 'string', required: true, editable: true } }),
|
|
88
|
+
'architecture-node': schema('architecture-node', { label: { type: 'string', required: true, editable: true }, layerId: { type: 'string', editable: true }, boundaryId: { type: 'string', editable: true }, position, size: { type: 'object', editable: true, properties: { width: { type: 'number', required: true, min: 1 }, height: { type: 'number', required: true, min: 1 } } }, ports: { type: 'array', editable: false, items: { type: 'object', properties: { id: { type: 'string', required: true }, side: { type: 'enum', values: ['left', 'right', 'top', 'bottom'] }, offset: { type: 'number', min: 0, max: 1 } } } }, description: { type: 'string', editable: true }, status }),
|
|
89
|
+
'architecture-edge': schema('architecture-edge', { from: { type: 'string', required: true, editable: true }, to: { type: 'string', required: true, editable: true }, label: { type: 'string', editable: true }, relation: { type: 'string', editable: true }, routing: { type: 'enum', values: ['straight', 'orthogonal', 'curved'], editable: true }, curveTension: { type: 'number', min: 0.2, max: 0.8, editable: true }, waypoints: { type: 'array', editable: true, items: { type: 'object', properties: { x: { type: 'number', required: true }, y: { type: 'number', required: true } } } }, status }),
|
|
90
|
+
'mindmap-node': schema('mindmap-node', { label: { type: 'string', required: true, editable: true }, parentId: { type: 'string', editable: true }, position, size: { type: 'object', editable: true, properties: { width: { type: 'number', required: true, min: 1 }, height: { type: 'number', required: true, min: 1 } } }, branch: { type: 'string', editable: true }, description: { type: 'string', editable: true }, status })
|
|
86
91
|
};
|
|
87
92
|
|
|
88
93
|
export function getBusinessSchema(name) {
|
package/src/spec.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { chartProfiles } from './capabilities.mjs';
|
|
6
6
|
import { themeModes, themePalettes, themePresets } from './theme.mjs';
|
|
7
|
+
import { validateDiagram } from './diagram.mjs';
|
|
7
8
|
|
|
8
9
|
const chartTypes = new Set(Object.keys(chartProfiles));
|
|
9
10
|
|
|
@@ -21,7 +22,7 @@ const defaults = {
|
|
|
21
22
|
grid: { visible: true },
|
|
22
23
|
labels: { enabled: false },
|
|
23
24
|
branding: { enabled: true },
|
|
24
|
-
interaction: { tooltip: true, hover: true, click: true, crosshair: false, zoom: false, brush: false, keyboard: true }
|
|
25
|
+
interaction: { tooltip: true, hover: true, click: true, crosshair: false, zoom: false, pan: false, brush: false, drag: false, edgeDrag: false, portConnect: false, keyboard: true }
|
|
25
26
|
,responsive: [], accessibility: { enabled: false }, editing: { enabled: false, mode: 'command', requireConfirmation: true, allowDelete: false, allowStructuralChanges: false }, xAxis: {}, yAxis: {}
|
|
26
27
|
};
|
|
27
28
|
|
|
@@ -68,7 +69,7 @@ export function normalizeSpec(input = {}) {
|
|
|
68
69
|
if (spec.branding.enabled === undefined) spec.branding = { ...spec.branding, enabled: true };
|
|
69
70
|
}
|
|
70
71
|
if (!spec.data) spec.data = { values: [] };
|
|
71
|
-
if (['flow', 'swimlane'].includes(spec.type) && spec.nodes && !spec.data.values) spec.data.values = [];
|
|
72
|
+
if (['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type) && spec.nodes && !spec.data.values) spec.data.values = [];
|
|
72
73
|
if (Array.isArray(spec.data)) spec.data = { values: spec.data };
|
|
73
74
|
if (!spec.encoding) spec.encoding = {};
|
|
74
75
|
if (['pie', 'funnel', 'gauge'].includes(spec.type) && !spec.encoding.category) spec.encoding.category = { field: 'name', type: 'category' };
|
|
@@ -76,7 +77,7 @@ export function normalizeSpec(input = {}) {
|
|
|
76
77
|
if (spec.type === 'scatter' && !input.encoding?.x) spec.encoding.x = { field: 'x', type: 'quantitative' };
|
|
77
78
|
if (spec.type === 'scatter' && !input.encoding?.y) spec.encoding.y = { field: 'y', type: 'quantitative' };
|
|
78
79
|
if (spec.type === 'heatmap') { spec.encoding.x ||= { field: 'x', type: 'category' }; spec.encoding.y ||= { field: 'y', type: 'category' }; spec.encoding.color ||= { field: 'value', type: 'quantitative' }; }
|
|
79
|
-
if (!['pie', 'funnel', 'gauge', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane'].includes(spec.type)) {
|
|
80
|
+
if (!['pie', 'funnel', 'gauge', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type)) {
|
|
80
81
|
spec.encoding.x = spec.encoding.x || { field: 'name', type: 'category' };
|
|
81
82
|
spec.encoding.y = spec.encoding.y || { field: 'value', type: 'quantitative' };
|
|
82
83
|
}
|
|
@@ -102,7 +103,12 @@ export function validateSpec(input) {
|
|
|
102
103
|
if (spec.type === 'pie' && spec.innerRadius != null && (!(Number(spec.innerRadius) >= 0) || Number(spec.innerRadius) >= 1)) errors.push({ code: 'INVALID_INNER_RADIUS', path: 'innerRadius', message: 'Pie innerRadius must be a ratio from 0 up to, but not including, 1.', suggestion: 'Use a value such as 0.55.' });
|
|
103
104
|
if (spec.type === 'radar' && (!Array.isArray(spec.indicators) || spec.indicators.length < 3)) errors.push({ code: 'INVALID_INDICATORS', path: 'indicators', message: 'Radar requires at least three indicators.', suggestion: 'Declare indicator name, field, min, and max.' });
|
|
104
105
|
if (!spec.data || !Array.isArray(spec.data.values)) errors.push({ code: 'INVALID_DATA', path: 'data.values', message: 'data.values must be an array.', suggestion: 'Pass an array of row objects.' });
|
|
105
|
-
if (['flow', 'swimlane'].includes(spec.type) && !Array.isArray(spec.nodes) && !Array.isArray(spec.data?.nodes)) errors.push({ code: 'INVALID_NODES', path: 'nodes', message:
|
|
106
|
+
if (['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type) && !Array.isArray(spec.nodes) && !Array.isArray(spec.data?.nodes)) errors.push({ code: 'INVALID_NODES', path: 'nodes', message: `${spec.type} charts require nodes.`, suggestion: 'Pass nodes on the Spec or in data.nodes.' });
|
|
107
|
+
if (['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type)) {
|
|
108
|
+
const diagramInput = { type: spec.type, ...(Array.isArray(spec.nodes) ? { nodes: spec.nodes } : {}), ...(Array.isArray(spec.data?.nodes) ? { nodes: spec.data.nodes } : {}), ...(Array.isArray(spec.edges) ? { edges: spec.edges } : {}), ...(Array.isArray(spec.data?.edges) ? { edges: spec.data.edges } : {}), ...(Array.isArray(spec.lanes) ? { lanes: spec.lanes } : {}), ...(Array.isArray(spec.groups) ? { groups: spec.groups } : {}), ...(Array.isArray(spec.layers) ? { layers: spec.layers } : {}), ...(Array.isArray(spec.boundaries) ? { boundaries: spec.boundaries } : {}), ...(spec.diagram ? { diagram: spec.diagram } : {}) };
|
|
109
|
+
const diagramValidation = validateDiagram(diagramInput);
|
|
110
|
+
diagramValidation.errors.forEach(error => errors.push({ ...error, path: error.path || 'diagram' }));
|
|
111
|
+
}
|
|
106
112
|
if (['gantt', 'timeline', 'milestone'].includes(spec.type)) {
|
|
107
113
|
const rows = spec.data.values, dateKey = spec.type === 'gantt' ? 'start/end' : 'date';
|
|
108
114
|
if (!rows.some(row => row.start || row.end || row.date)) errors.push({ code: 'INVALID_INTERVALS', path: 'data.values', message: `${spec.type} rows require ${dateKey} dates.`, suggestion: 'Add ISO date values to each row.' });
|
package/src/theme.mjs
CHANGED
|
@@ -66,7 +66,7 @@ const presets = {
|
|
|
66
66
|
};
|
|
67
67
|
|
|
68
68
|
const projectTypes = new Set(['gantt', 'timeline', 'milestone', 'burndown']);
|
|
69
|
-
const diagramTypes = new Set(['flow', 'swimlane']);
|
|
69
|
+
const diagramTypes = new Set(['flow', 'swimlane', 'architecture', 'mindmap']);
|
|
70
70
|
const structuredKeys = new Set(['preset', 'mode', 'palette', 'tokens']);
|
|
71
71
|
|
|
72
72
|
function clone(value) { return value == null ? value : JSON.parse(JSON.stringify(value)); }
|