@taylorwong/ichartjs 2.0.5 → 2.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +11 -4
  3. package/docs/agent/README.md +3 -1
  4. package/docs/agent/coding-agent-integration.md +6 -2
  5. package/docs/agent/development/iteration-11.md +44 -0
  6. package/docs/agent/development/iteration-12.md +101 -0
  7. package/docs/agent/development/roadmap.md +29 -3
  8. package/docs/agent/development-guide.md +1 -1
  9. package/docs/agent/diagram-scenario.md +39 -11
  10. package/docs/agent/editing-contract.md +5 -0
  11. package/docs/agent/frontend-integration.md +1 -1
  12. package/docs/agent/runtime-contract.md +11 -1
  13. package/docs/agent/theme-guide.md +63 -0
  14. package/docs/agent/usage-scenarios.md +40 -1
  15. package/docs/agent/zh-CN/README.md +1 -1
  16. package/docs/agent/zh-CN/coding-agent-integration.md +6 -2
  17. package/docs/agent/zh-CN/diagram-scenario.md +32 -3
  18. package/docs/agent/zh-CN/editing-contract.md +2 -0
  19. package/docs/agent/zh-CN/frontend-integration.md +1 -1
  20. package/docs/agent/zh-CN/iteration-12.md +98 -0
  21. package/docs/agent/zh-CN/runtime-contract.md +3 -1
  22. package/docs/agent/zh-CN/theme-guide.md +63 -0
  23. package/docs/agent/zh-CN/usage-scenarios.md +40 -1
  24. package/docs/manifests/capabilities.json +37 -2
  25. package/docs/manifests/commands.json +10 -9
  26. package/docs/manifests/schemas.json +5 -2
  27. package/package.json +1 -1
  28. package/skills/ichartjs/SKILL.md +11 -2
  29. package/src/capabilities.mjs +65 -8
  30. package/src/charts.mjs +127 -44
  31. package/src/command.mjs +4 -3
  32. package/src/diagram-interaction.mjs +55 -11
  33. package/src/diagram.mjs +149 -36
  34. package/src/edit-controller.mjs +6 -3
  35. package/src/edit.mjs +17 -7
  36. package/src/index.mjs +68 -17
  37. package/src/layout.mjs +39 -0
  38. package/src/preferences-ui.mjs +135 -0
  39. package/src/preferences.mjs +194 -0
  40. package/src/project.mjs +64 -24
  41. package/src/renderer.mjs +2 -2
  42. package/src/scene.mjs +32 -1
  43. package/src/schema.mjs +7 -2
  44. package/src/spec.mjs +10 -4
  45. package/src/theme.mjs +1 -1
  46. package/types/index.d.ts +39 -3
@@ -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, routeEdge } from './diagram.mjs';
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 geometry = { x: row.position?.x ?? generated?.x ?? plot.x + rank * gapX + 12, y: row.position?.y ?? 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 };
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
- scene.add({ id: `node-${row.id}`, type: 'rect', geometry, bounds: { ...geometry }, style: { fill: spec.colors[lane % spec.colors.length] }, dataRef: { nodeId: row.id, dataIndex: index, datum: row, groupId: row.groupId || null }, interactive: true, zIndex: 2 });
248
- (row.ports || []).forEach(port => {
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 points = routeEdge({ ...edge, grid: spec.diagram?.grid || 8, obstacles, fromPortDefinition: fromNode.groupId && collapsedGroups.has(fromNode.groupId) ? null : fromNode?.ports?.find(port => port.id === edge.fromPort), toPortDefinition: toNode.groupId && collapsedGroups.has(toNode.groupId) ? null : toNode?.ports?.find(port => port.id === edge.toPort) }, from, to, edge.routing || spec.diagram?.routing || 'orthogonal');
276
- arrow(scene, `edge-${index}`, points, { from: edge.from, to: edge.to, edgeId: edge.id || `edge-${index}`, status: edge.status, routing: edge.routing || spec.diagram?.routing || 'orthogonal', fromGroupId: fromNode.groupId || null, toGroupId: toNode.groupId || null }, edge.critical === true);
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(150, spec.width * 0.32) : spec.padding.left;
303
- const plotTop = Math.max(spec.padding.top, spec.title?.subtitle ? 72 : spec.title?.text ? 56 : spec.padding.top) + (spec.type === 'burndown' ? 16 : 0);
304
- const state = { plot: { x: left, y: plotTop, width: Math.max(1, spec.width - left - spec.padding.right), height: Math.max(1, spec.height - plotTop - spec.padding.bottom - 16) }, linked, projectAnalytics: { linked } };
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, 22, { fill: spec.theme.text, font: spec.theme.typography.title.font, textAnchor: 'middle' });
325
- if (spec.title?.subtitle) text(scene, 'subtitle', spec.title.subtitle, spec.width / 2, 40, { fill: spec.theme.muted, font: spec.theme.typography.subtitle.font, textAnchor: 'middle' });
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 = scene.find(`node-${node.dataRef.from}`), destination = scene.find(`node-${node.dataRef.to}`);
396
+ const source = nodeGeometries.get(node.dataRef.from), destination = nodeGeometries.get(node.dataRef.to);
360
397
  if (!source || !destination) return;
361
- const start = { x: source.geometry.x + source.geometry.width, y: source.geometry.y + source.geometry.height / 2 }, end = { x: destination.geometry.x, y: destination.geometry.y + destination.geometry.height / 2 };
362
- const middle = (start.x + end.x) / 2;
363
- node.geometry.points = node.dataRef.routing === 'straight' ? [start, end] : node.dataRef.routing === 'curved' ? [start, { x: start.x + Math.max(30, Math.abs(end.x - start.x) * 0.4), y: start.y }, { x: end.x - Math.max(30, Math.abs(end.x - start.x) * 0.4), y: end.y }, end] : [start, { x: middle, y: start.y }, { x: middle, y: end.y }, end];
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
- 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(); 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'; 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); }
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') element.setAttribute('d', `${g.points.map((p, i) => `${i ? 'L' : 'M'} ${p.x} ${p.y}`).join(' ')}${g.closed ? ' Z' : ''}`); else if (node.type === 'text') { element.setAttribute('x', g.x); element.setAttribute('y', 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); }
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.bounds && x >= node.bounds.x && x <= node.bounds.x + node.bounds.width && y >= node.bounds.y && y <= node.bounds.y + node.bounds.height) hits.push(node); }); return hits.sort((a, b) => b.zIndex - a.zIndex)[0] || null; }
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: 'Flow and swimlane charts require nodes.', suggestion: 'Pass nodes on the Spec or in data.nodes.' });
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)); }