@taylorwong/ichartjs 2.0.6 → 2.0.8

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 (53) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +2 -2
  3. package/agent-recipes/minimal-specs.json +25 -0
  4. package/docs/agent/README.md +3 -1
  5. package/docs/agent/charting-scenario.md +46 -1
  6. package/docs/agent/coding-agent-integration.md +1 -1
  7. package/docs/agent/development/iteration-12.md +101 -0
  8. package/docs/agent/development/roadmap.md +2 -1
  9. package/docs/agent/development-guide.md +1 -1
  10. package/docs/agent/diagram-scenario.md +39 -11
  11. package/docs/agent/editing-contract.md +5 -0
  12. package/docs/agent/frontend-integration.md +1 -1
  13. package/docs/agent/project-scenario.md +1 -0
  14. package/docs/agent/quickstart.md +30 -0
  15. package/docs/agent/runtime-contract.md +19 -2
  16. package/docs/agent/theme-guide.md +33 -1
  17. package/docs/agent/usage-scenarios.md +3 -1
  18. package/docs/agent/zh-CN/README.md +1 -1
  19. package/docs/agent/zh-CN/charting-scenario.md +31 -1
  20. package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
  21. package/docs/agent/zh-CN/diagram-scenario.md +32 -3
  22. package/docs/agent/zh-CN/editing-contract.md +2 -0
  23. package/docs/agent/zh-CN/frontend-integration.md +1 -1
  24. package/docs/agent/zh-CN/iteration-12.md +98 -0
  25. package/docs/agent/zh-CN/quickstart.md +30 -0
  26. package/docs/agent/zh-CN/runtime-contract.md +7 -1
  27. package/docs/agent/zh-CN/theme-guide.md +33 -1
  28. package/docs/agent/zh-CN/usage-scenarios.md +3 -1
  29. package/docs/manifests/capabilities.json +42 -3
  30. package/docs/manifests/commands.json +10 -9
  31. package/docs/manifests/schemas.json +5 -2
  32. package/examples/agent-workflow.mjs +2 -1
  33. package/package.json +1 -1
  34. package/skills/ichartjs/SKILL.md +19 -9
  35. package/skills/ichartjs/references/agent-contract.md +5 -2
  36. package/skills/ichartjs/references/chart-selection.md +2 -0
  37. package/src/capabilities.mjs +83 -13
  38. package/src/charts.mjs +202 -58
  39. package/src/command.mjs +4 -3
  40. package/src/diagram-interaction.mjs +55 -11
  41. package/src/diagram.mjs +149 -36
  42. package/src/edit-controller.mjs +6 -3
  43. package/src/edit.mjs +17 -7
  44. package/src/index.mjs +44 -17
  45. package/src/layout.mjs +39 -0
  46. package/src/preferences-ui.mjs +73 -24
  47. package/src/project.mjs +64 -24
  48. package/src/renderer.mjs +2 -2
  49. package/src/scene.mjs +32 -1
  50. package/src/schema.mjs +7 -2
  51. package/src/spec.mjs +88 -6
  52. package/src/theme.mjs +1 -1
  53. package/types/index.d.ts +28 -7
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,8 +22,9 @@ 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
- ,responsive: [], accessibility: { enabled: false }, editing: { enabled: false, mode: 'command', requireConfirmation: true, allowDelete: false, allowStructuralChanges: false }, xAxis: {}, yAxis: {}
25
+ interaction: { tooltip: true, hover: true, click: true, crosshair: false, zoom: false, pan: false, brush: false, drag: false, edgeDrag: false, portConnect: false, keyboard: true }
26
+ ,responsive: [], accessibility: { enabled: false }, editing: { enabled: false, mode: 'command', requireConfirmation: true, allowDelete: false, allowStructuralChanges: false }, xAxis: {}, yAxis: { nice: true, ticks: 'auto' }
27
+ ,locale: 'en-US'
26
28
  };
27
29
 
28
30
  function clone(value) {
@@ -58,6 +60,41 @@ function dependencyErrors(rows) {
58
60
  return errors;
59
61
  }
60
62
 
63
+ const encodingChannels = {
64
+ line: ['x', 'y'], area: ['x', 'y'], bar: ['x', 'y'], column: ['x', 'y'], scatter: ['x', 'y'],
65
+ pie: ['category', 'value'], funnel: ['category', 'value'], gauge: ['category', 'value'], heatmap: ['x', 'y', 'color'], radar: [],
66
+ gantt: [], timeline: [], milestone: [], burndown: [], flow: [], swimlane: [], architecture: [], mindmap: []
67
+ };
68
+
69
+ const presentationEncodingKeys = new Set(['title', 'format', 'labels', 'legend']);
70
+
71
+ function finiteDomain(domain) {
72
+ return Array.isArray(domain) && domain.length === 2 && domain.every(value => Number.isFinite(Number(value))) && Number(domain[1]) > Number(domain[0]);
73
+ }
74
+
75
+ function validateEncodingContract(input, spec, errors) {
76
+ const rows = Array.isArray(spec.data?.values) ? spec.data.values : [], fields = new Set(rows.flatMap(row => Object.keys(row || {})));
77
+ if (!rows.length) return;
78
+ const rawEncoding = input.encoding && typeof input.encoding === 'object' ? input.encoding : {};
79
+ const allowed = new Set(encodingChannels[spec.type] || []);
80
+ Object.keys(rawEncoding).forEach(channel => {
81
+ if (presentationEncodingKeys.has(channel) || allowed.has(channel)) return;
82
+ errors.push({ code: 'UNSUPPORTED_ENCODING_CHANNEL', path: `encoding.${channel}`, message: `${spec.type} does not use encoding.${channel}.`, expected: [...allowed], suggestion: allowed.size ? `Use encoding.${[...allowed].join(' or encoding.')} for ${spec.type}.` : 'Remove field encodings from this chart type.' });
83
+ });
84
+ const check = (path, encoding) => {
85
+ const items = Array.isArray(encoding) ? encoding : [encoding];
86
+ items.forEach((item, index) => {
87
+ if (!item) return;
88
+ const field = item.field, itemPath = Array.isArray(encoding) ? `${path}.${index}.field` : `${path}.field`;
89
+ if (typeof field !== 'string' || !field) errors.push({ code: 'INVALID_ENCODING_FIELD', path: itemPath, message: 'Encoding fields must be non-empty strings.', suggestion: 'Use a field name that exists in data.values.' });
90
+ else if (!fields.has(field)) errors.push({ code: 'MISSING_ENCODING_FIELD', path: itemPath, message: `Field ${field} is not present in data.values.`, expected: [...fields], suggestion: 'Inspect the data schema and use an existing field.' });
91
+ });
92
+ };
93
+ if (['pie', 'funnel', 'gauge'].includes(spec.type) && (rawEncoding.x !== undefined || rawEncoding.y !== undefined)) return;
94
+ (encodingChannels[spec.type] || []).forEach(channel => check(`encoding.${channel}`, spec.encoding?.[channel]));
95
+ if (spec.type === 'radar') (spec.indicators || []).forEach((indicator, index) => check(`indicators.${index}`, indicator));
96
+ }
97
+
61
98
  export function normalizeSpec(input = {}) {
62
99
  const spec = merge(defaults, input);
63
100
  if (input.branding === undefined && input.theme && typeof input.theme === 'object' && input.theme.branding !== undefined) spec.branding = clone(input.theme.branding);
@@ -68,7 +105,7 @@ export function normalizeSpec(input = {}) {
68
105
  if (spec.branding.enabled === undefined) spec.branding = { ...spec.branding, enabled: true };
69
106
  }
70
107
  if (!spec.data) spec.data = { values: [] };
71
- if (['flow', 'swimlane'].includes(spec.type) && spec.nodes && !spec.data.values) spec.data.values = [];
108
+ if (['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type) && spec.nodes && !spec.data.values) spec.data.values = [];
72
109
  if (Array.isArray(spec.data)) spec.data = { values: spec.data };
73
110
  if (!spec.encoding) spec.encoding = {};
74
111
  if (['pie', 'funnel', 'gauge'].includes(spec.type) && !spec.encoding.category) spec.encoding.category = { field: 'name', type: 'category' };
@@ -76,14 +113,14 @@ export function normalizeSpec(input = {}) {
76
113
  if (spec.type === 'scatter' && !input.encoding?.x) spec.encoding.x = { field: 'x', type: 'quantitative' };
77
114
  if (spec.type === 'scatter' && !input.encoding?.y) spec.encoding.y = { field: 'y', type: 'quantitative' };
78
115
  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)) {
116
+ if (!['pie', 'funnel', 'gauge', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type)) {
80
117
  spec.encoding.x = spec.encoding.x || { field: 'name', type: 'category' };
81
118
  spec.encoding.y = spec.encoding.y || { field: 'value', type: 'quantitative' };
82
119
  }
83
120
  return spec;
84
121
  }
85
122
 
86
- export function validateSpec(input) {
123
+ export function validateSpec(input = {}) {
87
124
  const spec = normalizeSpec(input);
88
125
  const errors = [], warnings = [], normalizations = [];
89
126
  if (!chartTypes.has(spec.type)) errors.push({ code: 'INVALID_TYPE', path: 'type', message: `Unsupported chart type: ${spec.type}`, suggestion: 'Use a type returned by getCapabilities().' });
@@ -96,13 +133,52 @@ export function validateSpec(input) {
96
133
  if (spec.theme.branding != null && typeof spec.theme.branding !== 'boolean' && !(spec.theme.branding && typeof spec.theme.branding === 'object')) errors.push({ code: 'INVALID_BRANDING', path: 'theme.branding', message: 'theme.branding must be a boolean or a { enabled: boolean } object.', suggestion: 'Use theme: { branding: false } or theme: { branding: { enabled: false } }.' });
97
134
  else if (spec.theme.branding && typeof spec.theme.branding === 'object' && (Object.keys(spec.theme.branding).some(key => key !== 'enabled') || typeof spec.theme.branding.enabled !== 'boolean')) errors.push({ code: 'INVALID_BRANDING', path: 'theme.branding', message: 'theme.branding objects only support a boolean enabled property.', suggestion: 'Use theme: { branding: { enabled: false } }.' });
98
135
  }
136
+ if (typeof spec.locale !== 'string' || !spec.locale.trim()) errors.push({ code: 'INVALID_LOCALE', path: 'locale', message: 'locale must be a non-empty BCP 47 locale string.', suggestion: 'Use for example locale: "en-US" or locale: "zh-CN".' });
137
+ validateEncodingContract(input, spec, errors);
138
+ const axisEncodings = [['x', 'xAxis'], ['y', 'yAxis']];
139
+ axisEncodings.forEach(([encodingName, axisName]) => {
140
+ const encoding = spec.encoding?.[encodingName];
141
+ const encodingItems = Array.isArray(encoding) ? encoding : [encoding];
142
+ encodingItems.forEach((item, index) => {
143
+ if (!item || typeof item !== 'object') return;
144
+ const pathPrefix = Array.isArray(encoding) ? `encoding.${encodingName}.${index}` : `encoding.${encodingName}`;
145
+ if (item.title !== undefined) warnings.push({ code: 'MISPLACED_AXIS_TITLE', path: `${pathPrefix}.title`, message: `Axis titles belong on ${axisName}.title, not on an encoding.`, suggestion: `Move it to ${axisName}: { title: ... }.` });
146
+ if (item.format !== undefined) warnings.push({ code: 'MISPLACED_AXIS_FORMAT', path: `${pathPrefix}.format`, message: `Axis formats belong on ${axisName}.format, not on an encoding.`, suggestion: `Move it to ${axisName}: { format: ... }.` });
147
+ });
148
+ });
149
+ if (spec.encoding?.labels !== undefined) warnings.push({ code: 'MISPLACED_LABELS', path: 'encoding.labels', message: 'Data labels are a chart-level option.', suggestion: 'Move it to labels: { enabled: true, ... }.' });
150
+ if (spec.encoding?.legend !== undefined) warnings.push({ code: 'MISPLACED_LEGEND', path: 'encoding.legend', message: 'Legend visibility is a chart-level option.', suggestion: 'Move it to legend: { visible: false }.' });
151
+ [['xAxis', spec.xAxis], ['yAxis', spec.yAxis]].forEach(([axisName, axis]) => {
152
+ ['min', 'max'].forEach(bound => {
153
+ if (axis?.[bound] !== undefined || axis?.right?.[bound] !== undefined) warnings.push({ code: 'UNSUPPORTED_AXIS_DOMAIN', path: `${axisName}.${bound}`, message: 'Use axis.domain rather than axis.min/axis.max for an explicit numeric domain.', suggestion: 'Use yAxis: { domain: [0, 2000] } or yAxis: { nice: true }.' });
154
+ });
155
+ if (axisName === 'xAxis' && axis?.domain !== undefined) warnings.push({ code: 'UNSUPPORTED_AXIS_DOMAIN', path: 'xAxis.domain', message: 'Explicit x-axis domain is not supported by the current categorical/time layout.', suggestion: 'Use yAxis.domain for numeric value axes; x-axis ranges are derived from records.' });
156
+ if (axisName === 'xAxis' && (axis?.nice !== undefined || axis?.ticks !== undefined)) warnings.push({ code: 'UNSUPPORTED_AXIS_TICKS', path: 'xAxis', message: 'Nice domains and tick counts currently apply to yAxis, not categorical/time x-axis layouts.', suggestion: 'Move numeric scale controls to yAxis.' });
157
+ if (axisName === 'xAxis') return;
158
+ [axis, axis?.right].forEach((axisConfig, index) => {
159
+ if (!axisConfig) return;
160
+ const pathPrefix = index ? `${axisName}.right` : axisName;
161
+ if (axisConfig.domain !== undefined && !(Array.isArray(axisConfig.domain) && axisConfig.domain.length === 2 && axisConfig.domain.every(value => Number.isFinite(Number(value))) && Number(axisConfig.domain[1]) > Number(axisConfig.domain[0]))) errors.push({ code: 'INVALID_AXIS_DOMAIN', path: `${pathPrefix}.domain`, message: 'Axis domain must be [min, max] with two finite numbers and max greater than min.', suggestion: 'Use for example yAxis: { domain: [0, 2000] }.' });
162
+ if (axisConfig.nice !== undefined && typeof axisConfig.nice !== 'boolean') errors.push({ code: 'INVALID_AXIS_NICE', path: `${pathPrefix}.nice`, message: 'Axis nice must be a boolean.', suggestion: 'Use nice: true or nice: false.' });
163
+ if (axisConfig.ticks !== undefined && axisConfig.ticks !== 'auto' && !(Number.isInteger(axisConfig.ticks) && axisConfig.ticks >= 2)) errors.push({ code: 'INVALID_AXIS_TICKS', path: `${pathPrefix}.ticks`, message: 'Axis ticks must be auto or an integer of at least 2 labels.', suggestion: 'Use ticks: "auto" or ticks: 5.' });
164
+ });
165
+ });
99
166
  if (Array.isArray(spec.encoding.y) && spec.encoding.y.length > 2) errors.push({ code: 'TOO_MANY_AXES', path: 'encoding.y', message: 'Only two quantitative axes are supported in this iteration.', suggestion: 'Use at most two y encodings.' });
100
167
  if (spec.stack && !['bar', 'column', 'area'].includes(spec.type)) errors.push({ code: 'INVALID_STACK', path: 'stack', message: 'Stacking is supported by bar, column, and area charts.', suggestion: 'Remove stack or use a supported chart type.' });
101
168
  if (spec.stack && !['stacked', 'percent'].includes(typeof spec.stack === 'string' ? spec.stack : spec.stack.mode)) errors.push({ code: 'INVALID_STACK_MODE', path: 'stack', message: 'Stack mode must be stacked or percent.', suggestion: 'Use stack: "stacked" or stack: "percent".' });
102
169
  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.' });
170
+ if (spec.type === 'gauge') {
171
+ if (spec.domain === undefined) errors.push({ code: 'MISSING_GAUGE_DOMAIN', path: 'domain', message: 'Gauge requires an explicit numeric domain to avoid silently clipping values.', suggestion: 'Use domain: [0, 100] for a percentage KPI or declare the business range.' });
172
+ else if (!finiteDomain(spec.domain)) errors.push({ code: 'INVALID_GAUGE_DOMAIN', path: 'domain', message: 'Gauge domain must be [min, max] with finite numbers and max greater than min.', suggestion: 'Use for example domain: [0, 100].' });
173
+ }
103
174
  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
175
  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.' });
176
+ 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.' });
177
+ if (['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type)) {
178
+ 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 } : {}) };
179
+ const diagramValidation = validateDiagram(diagramInput);
180
+ diagramValidation.errors.forEach(error => errors.push({ ...error, path: error.path || 'diagram' }));
181
+ }
106
182
  if (['gantt', 'timeline', 'milestone'].includes(spec.type)) {
107
183
  const rows = spec.data.values, dateKey = spec.type === 'gantt' ? 'start/end' : 'date';
108
184
  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.' });
@@ -111,6 +187,12 @@ export function validateSpec(input) {
111
187
  if (spec.type === 'gantt') errors.push(...dependencyErrors(spec.data.values));
112
188
  if (spec.type === 'pie' && spec.data.values.length > 8) warnings.push({ code: 'HIGH_CARDINALITY_PIE', path: 'data.values', message: `Pie contains ${spec.data.values.length} categories.`, expected: '8 or fewer categories', suggestion: 'Use bar/column or group smaller categories.' });
113
189
  if (spec.type === 'radar' && Array.isArray(spec.indicators) && spec.indicators.some(indicator => !Number.isFinite(Number(indicator.min)) || !Number.isFinite(Number(indicator.max)))) warnings.push({ code: 'AMBIGUOUS_RADAR_DOMAIN', path: 'indicators', message: 'Radar indicator domains are incomplete.', expected: 'finite min and max for every indicator', suggestion: 'Declare explicit domains, especially for mixed units.' });
190
+ const profile = chartProfiles[spec.type], inputOptions = input && typeof input === 'object' ? input : {};
191
+ [['legend', 'legend'], ['grid', 'grid'], ['labels', 'labels']].forEach(([option, feature]) => {
192
+ if (inputOptions[option] !== undefined && profile?.features?.[feature] !== 'supported') warnings.push({ code: `UNSUPPORTED_${option.toUpperCase()}`, path: option, message: `${spec.type} does not support ${option} configuration in the current contract.`, suggestion: 'Remove the option or use a chart type that declares this feature.' });
193
+ });
194
+ if ((inputOptions.xAxis !== undefined || inputOptions.yAxis !== undefined) && !['line', 'area', 'bar', 'column', 'scatter'].includes(spec.type)) warnings.push({ code: 'UNSUPPORTED_AXIS', path: inputOptions.xAxis !== undefined ? 'xAxis' : 'yAxis', message: `${spec.type} does not expose Cartesian axis configuration.`, suggestion: 'Use chart-specific options such as domain, colorScale, or indicators.' });
195
+ if (spec.type === 'swimlane' && !((Array.isArray(spec.lanes) && spec.lanes.length) || (Array.isArray(spec.data?.lanes) && spec.data.lanes.length))) errors.push({ code: 'MISSING_REQUIRED', path: 'lanes', message: 'Swimlane requires at least one lane.', suggestion: 'Provide lanes: [{ id, label }].' });
114
196
  const supportedInteractions = chartProfiles[spec.type]?.interactions || [];
115
197
  Object.entries(spec.interaction || {}).forEach(([name, enabled]) => { if (enabled && !supportedInteractions.includes(name) && !['hover', 'click'].includes(name)) warnings.push({ code: 'UNSUPPORTED_INTERACTION', path: `interaction.${name}`, message: `${name} is not declared for ${spec.type}.`, expected: supportedInteractions, suggestion: 'Disable the interaction or use a compatible chart type.' }); });
116
198
  if (spec.branding != null && typeof spec.branding !== 'boolean' && !(spec.branding && typeof spec.branding === 'object')) {
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)); }