@taylorwong/ichartjs 2.0.12 → 2.0.14

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 (42) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +2 -2
  3. package/docs/agent/charting-scenario.md +3 -1
  4. package/docs/agent/coding-agent-integration.md +1 -1
  5. package/docs/agent/development/2.0-release-readiness.md +4 -0
  6. package/docs/agent/development/iteration-13.md +278 -0
  7. package/docs/agent/development/release-sop.md +1 -1
  8. package/docs/agent/development/roadmap.md +2 -1
  9. package/docs/agent/diagram-scenario.md +4 -0
  10. package/docs/agent/editing-contract.md +3 -0
  11. package/docs/agent/frontend-integration.md +1 -1
  12. package/docs/agent/project-scenario.md +3 -0
  13. package/docs/agent/runtime-contract.md +4 -0
  14. package/docs/agent/usage-scenarios.md +1 -1
  15. package/docs/agent/zh-CN/charting-scenario.md +3 -1
  16. package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
  17. package/docs/agent/zh-CN/diagram-scenario.md +17 -1
  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/project-scenario.md +4 -2
  21. package/docs/agent/zh-CN/runtime-contract.md +3 -1
  22. package/docs/agent/zh-CN/usage-scenarios.md +1 -1
  23. package/docs/manifests/capabilities.json +1310 -32
  24. package/docs/manifests/commands.json +7 -7
  25. package/docs/manifests/schemas.json +182 -10
  26. package/package.json +24 -4
  27. package/skills/ichartjs/SKILL.md +1 -1
  28. package/src/capabilities.mjs +10 -36
  29. package/src/chart-lifecycle.mjs +31 -0
  30. package/src/charts.mjs +49 -19
  31. package/src/command.mjs +3 -2
  32. package/src/contract-registry.mjs +59 -0
  33. package/src/diagram.mjs +30 -0
  34. package/src/edit-controller.mjs +1 -1
  35. package/src/errors.mjs +31 -0
  36. package/src/index.mjs +142 -10
  37. package/src/layout.mjs +26 -1
  38. package/src/project.mjs +45 -16
  39. package/src/schema.mjs +1 -0
  40. package/src/spec.mjs +45 -3
  41. package/types/consumer-fixture.ts +35 -0
  42. package/types/index.d.ts +33 -3
@@ -0,0 +1,59 @@
1
+ /**
2
+ * JSON-safe public contract metadata shared by runtime discovery and release checks.
3
+ * Keep this module dependency-free so browser consumers can import the registry safely.
4
+ */
5
+ export const contractVersion = '1.1';
6
+ export const chartTypes = ['line', 'area', 'bar', 'column', 'pie', 'scatter', 'funnel', 'gauge', 'heatmap', 'radar', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'];
7
+ export const renderers = ['canvas', 'svg'];
8
+ export const exportTypes = ['png', 'jpeg', 'svg', 'json'];
9
+ export const interactionDefaults = { zoom: false, pan: false, brush: false, drag: false, edgeDrag: false, portConnect: false, editing: false };
10
+ export const commandTypes = ['updateField', 'updateRecord', 'updateTask', 'shiftTask', 'updateProgress', 'addDependency', 'removeDependency', 'updateMilestone', 'moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'removeEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'];
11
+ export const diagramOperations = ['moveNode', 'moveNodes', 'resizeNode', 'alignNodes', 'snapNodes', 'moveNodeToLane', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'removeEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'];
12
+ export const businessModels = ['project-task', 'timeline-event', 'milestone', 'burndown-sample', 'flow-node', 'flow-edge', 'swimlane', 'architecture-node', 'architecture-edge', 'mindmap-edge', 'mindmap-node'];
13
+ export const diagramEdgeModels = ['flow-edge', 'architecture-edge', 'mindmap-edge'];
14
+
15
+ const status = (supported, notApplicable = []) => Object.fromEntries(supported.map(name => [name, 'supported']).concat(notApplicable.map(name => [name, 'not-applicable'])));
16
+ const commonPresentation = ['title', 'subtitle', 'theme', 'responsive', 'empty-state', 'invalid-data-state', 'export', 'branding'];
17
+
18
+ export const chartDefinitions = {
19
+ line: { family: 'cartesian', intents: ['trend', 'time-series'], required: ['dimension', 'measure'], optional: ['series'], interactions: ['hover', 'tooltip', 'selection', 'crosshair', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'grid', 'legend', 'labels', 'formatting', 'multi-series', 'dual-axis']) },
20
+ area: { family: 'cartesian', intents: ['trend', 'composition'], required: ['dimension', 'measure'], optional: ['series'], interactions: ['hover', 'tooltip', 'selection', 'crosshair', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'grid', 'legend', 'labels', 'formatting', 'multi-series', 'stacked', 'percent-stacked']) },
21
+ bar: { family: 'cartesian', intents: ['comparison', 'ranking'], required: ['dimension', 'measure'], optional: ['series'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'grid', 'legend', 'labels', 'formatting', 'multi-series', 'stacked', 'percent-stacked']) },
22
+ column: { family: 'cartesian', intents: ['comparison', 'distribution', 'composition'], required: ['dimension', 'measure'], optional: ['series'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'grid', 'legend', 'labels', 'formatting', 'multi-series', 'stacked', 'percent-stacked', 'mixed-line-column', 'dual-axis', 'bin']) },
23
+ pie: { family: 'part-to-whole', intents: ['part-to-whole'], required: ['dimension', 'measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'legend', 'labels', 'formatting', 'donut', 'zero-total'], ['axes', 'grid', 'zoom', 'pan']) },
24
+ scatter: { family: 'cartesian', intents: ['relationship', 'correlation'], required: ['measure', 'measure'], optional: ['dimension'], interactions: ['hover', 'tooltip', 'selection', 'brush', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'grid', 'labels', 'formatting'], ['stacked']) },
25
+ funnel: { family: 'stage', intents: ['funnel', 'conversion'], required: ['dimension', 'measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting'], ['axes', 'grid', 'zoom', 'pan']) },
26
+ gauge: { family: 'indicator', intents: ['progress', 'single-value'], required: ['measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting', 'domain'], ['axes', 'grid', 'legend', 'zoom', 'pan']) },
27
+ heatmap: { family: 'matrix', intents: ['matrix', 'correlation-grid'], required: ['dimension', 'dimension', 'measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'axes', 'labels', 'formatting', 'color-scale', 'missing-values'], ['zoom', 'pan']) },
28
+ radar: { family: 'radial', intents: ['multidimensional', 'profile'], required: ['three-or-more-measures'], optional: ['series'], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'legend', 'labels', 'formatting', 'multi-series', 'indicator-domains', 'mixed-unit-warning'], ['axes', 'grid', 'zoom', 'pan']) },
29
+ gantt: { family: 'project', intents: ['schedule', 'variance'], required: ['id', 'start', 'end'], optional: ['dependencies', 'progress', 'baseline', 'actual'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard', 'linked-filter', 'linked-selection'], features: status([...commonPresentation, 'labels', 'formatting', 'critical-path', 'slack', 'baseline-actual-variance', 'working-calendar', 'dependencies']) },
30
+ timeline: { family: 'project', intents: ['timeline', 'milestone'], required: ['date', 'label'], optional: ['status'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting']) },
31
+ milestone: { family: 'project', intents: ['milestone'], required: ['date', 'label'], optional: ['baseline', 'actual'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting', 'baseline-actual-variance']) },
32
+ burndown: { family: 'project', intents: ['progress', 'release'], required: ['date', 'remaining'], optional: ['ideal', 'scopeChange'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'labels', 'formatting', 'scope-change', 'release-forecast']) },
33
+ flow: { family: 'diagram', intents: ['workflow'], required: ['nodes'], optional: ['edges', 'groups', 'ports'], interactions: ['hover', 'tooltip', 'selection', 'edge-selection', 'drag', 'edgeDrag', 'zoom', 'pan', 'keyboard', 'keyboard-edit', 'keyboard-port-connect', 'portConnect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'manual-routing', 'layout', 'groups', 'ports', 'history'], ['axes', 'grid', 'legend']) },
34
+ swimlane: { family: 'diagram', intents: ['responsibility'], required: ['nodes', 'lanes'], optional: ['edges', 'groups', 'ports'], interactions: ['hover', 'tooltip', 'selection', 'edge-selection', 'drag', 'edgeDrag', 'zoom', 'pan', 'keyboard', 'keyboard-edit', 'keyboard-port-connect', 'portConnect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'manual-routing', 'layout', 'groups', 'ports', 'lanes', 'history'], ['axes', 'grid', 'legend']) },
35
+ architecture: { family: 'diagram', intents: ['architecture', 'business-architecture', 'data-architecture', 'technical-architecture'], required: ['nodes'], optional: ['edges', 'layers', 'boundaries', 'groups', 'ports'], interactions: ['hover', 'tooltip', 'selection', 'edge-selection', 'drag', 'edgeDrag', 'zoom', 'pan', 'keyboard', 'keyboard-edit', 'keyboard-port-connect', 'portConnect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'manual-routing', 'layout', 'layers', 'boundaries', 'groups', 'ports', 'history'], ['axes', 'grid', 'legend']) },
36
+ mindmap: { family: 'diagram', intents: ['mindmap', 'hierarchy', 'brainstorm'], required: ['nodes'], optional: ['parentId', 'edges', 'groups'], interactions: ['hover', 'tooltip', 'selection', 'edge-selection', 'drag', 'edgeDrag', 'zoom', 'pan', 'keyboard', 'keyboard-edit', 'keyboard-port-connect', 'portConnect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'cubic-bezier', 'curve-tension', 'obstacle-fallback', 'manual-routing', 'layout', 'tree-layout', 'radial-layout', 'history'], ['axes', 'grid', 'legend']) }
37
+ };
38
+
39
+ export const chartProfiles = Object.fromEntries(chartTypes.map(type => [type, {
40
+ type,
41
+ ...chartDefinitions[type],
42
+ dataShapes: chartDefinitions[type].family === 'diagram' ? ['diagram-spec'] : chartDefinitions[type].family === 'project' ? ['records'] : ['records', 'data-table'],
43
+ renderers: [...renderers],
44
+ exports: [...exportTypes],
45
+ limits: type === 'pie' ? { recommendedCategories: 8 } : type === 'radar' ? { minimumIndicators: 3, recommendedIndicators: 8 } : type === 'heatmap' ? { recommendedCells: 2500 } : {}
46
+ }]));
47
+
48
+ export const contractRegistry = Object.freeze({
49
+ contractVersion,
50
+ chartTypes: [...chartTypes],
51
+ chartProfiles: JSON.parse(JSON.stringify(chartProfiles)),
52
+ renderers: [...renderers],
53
+ exportTypes: [...exportTypes],
54
+ interactionDefaults: { ...interactionDefaults },
55
+ commandTypes: [...commandTypes],
56
+ diagramOperations: [...diagramOperations],
57
+ businessModels: [...businessModels],
58
+ diagramEdgeModels: [...diagramEdgeModels]
59
+ });
package/src/diagram.mjs CHANGED
@@ -95,9 +95,39 @@ export function diagramGraph(spec) {
95
95
  return { spec: normalized, nodes, edges, incoming, outgoing };
96
96
  }
97
97
 
98
+ export function layoutArchitectureNodes(spec, bounds = { x: 0, y: 0, width: 640, height: 360 }) {
99
+ const normalized = normalizeDiagramSpec(spec), layers = normalized.layers, nodes = normalized.nodes;
100
+ if (!layers.length) return {};
101
+ const boxes = {}, grid = Number(normalized.diagram.grid) || 0, snap = value => grid > 0 ? Math.round(value / grid) * grid : value;
102
+ const layerHeight = bounds.height / layers.length;
103
+ layers.forEach((layer, layerIndex) => {
104
+ const members = nodes.filter(node => node.layerId === layer.id);
105
+ if (!members.length) return;
106
+ const widest = Math.max(72, ...members.map(node => Number(node.size?.width) || 112));
107
+ const columns = Math.max(1, Math.min(members.length, Math.floor((bounds.width + 12) / (widest + 12))));
108
+ const rows = Math.ceil(members.length / columns);
109
+ const slotWidth = bounds.width / columns, slotHeight = layerHeight / rows;
110
+ members.forEach((node, index) => {
111
+ const row = Math.floor(index / columns), column = index % columns;
112
+ const count = row === rows - 1 ? members.length - row * columns : columns;
113
+ const rowOffset = (bounds.width - count * slotWidth) / 2;
114
+ const width = Number(node.size?.width) || Math.min(112, Math.max(72, slotWidth - 12));
115
+ const height = Number(node.size?.height) || Math.min(36, Math.max(14, slotHeight - 6));
116
+ boxes[node.id] = {
117
+ x: snap(node.position?.x ?? bounds.x + rowOffset + column * slotWidth + (slotWidth - width) / 2),
118
+ y: snap(node.position?.y ?? bounds.y + layerIndex * layerHeight + row * slotHeight + (slotHeight - height) / 2),
119
+ width,
120
+ height
121
+ };
122
+ });
123
+ });
124
+ return boxes;
125
+ }
126
+
98
127
  export function layoutDiagram(spec, bounds = { x: 0, y: 0, width: 640, height: 360 }) {
99
128
  const graph = diagramGraph(spec), mode = graph.spec.diagram.layout, positions = new Map(), gapX = Math.max(150, bounds.width / Math.max(1, graph.nodes.length)), gapY = 72;
100
129
  if (mode === 'manual') graph.nodes.forEach(node => positions.set(node.id, { x: node.position?.x ?? bounds.x, y: node.position?.y ?? bounds.y }));
130
+ else if (graph.spec.diagram.mode === 'architecture' && graph.spec.layers.length) Object.entries(layoutArchitectureNodes(graph.spec, bounds)).forEach(([id, box]) => positions.set(id, { x: box.x, y: box.y }));
101
131
  else if (graph.spec.diagram.mode === 'mindmap') {
102
132
  const roots = graph.nodes.filter(node => !graph.incoming.get(node.id).length), children = new Map(graph.nodes.map(node => [node.id, []]));
103
133
  graph.edges.forEach(edge => { if (children.has(edge.from)) children.get(edge.from).push(edge.to); });
@@ -60,7 +60,7 @@ export class EditController {
60
60
  edgeSchema: spec.data.edgeSchema,
61
61
  type: spec.type,
62
62
  nodeModel: spec.type === 'architecture' ? 'architecture-node' : spec.type === 'mindmap' ? 'mindmap-node' : 'flow-node',
63
- edgeModel: spec.type === 'architecture' ? 'architecture-edge' : 'flow-edge',
63
+ edgeModel: spec.type === 'architecture' ? 'architecture-edge' : spec.type === 'mindmap' ? 'mindmap-edge' : 'flow-edge',
64
64
  validationOptions: { ...spec.validationOptions, references: { ...spec.validationOptions?.references, ...(diagram ? { 'flow-node': nodes.map(row => row.id), swimlane: lanes.map(row => row.id) } : {}) } },
65
65
  requireConfirmation: spec.editing?.requireConfirmation,
66
66
  grid: spec.diagram?.grid || 8,
package/src/errors.mjs ADDED
@@ -0,0 +1,31 @@
1
+ /** Structured public runtime errors for failed chart mutations. */
2
+ export class ChartValidationError extends Error {
3
+ constructor(operation, diagnostics = [], message = null) {
4
+ const details = Array.isArray(diagnostics) ? diagnostics : [];
5
+ const first = details[0] || {};
6
+ super(message || details.map(item => item.message).filter(Boolean).join(' ') || `Invalid ${operation} mutation.`);
7
+ this.name = 'ChartValidationError';
8
+ this.code = 'CHART_VALIDATION_FAILED';
9
+ this.operation = operation;
10
+ this.details = details;
11
+ this.errors = details;
12
+ this.path = first.path;
13
+ this.expected = first.expected;
14
+ this.received = first.received;
15
+ this.suggestion = first.suggestion;
16
+ }
17
+
18
+ toJSON() {
19
+ return {
20
+ name: this.name,
21
+ code: this.code,
22
+ operation: this.operation,
23
+ message: this.message,
24
+ details: this.details,
25
+ ...(this.path === undefined ? {} : { path: this.path }),
26
+ ...(this.expected === undefined ? {} : { expected: this.expected }),
27
+ ...(this.received === undefined ? {} : { received: this.received }),
28
+ ...(this.suggestion === undefined ? {} : { suggestion: this.suggestion })
29
+ };
30
+ }
31
+ }
package/src/index.mjs CHANGED
@@ -22,6 +22,9 @@ import { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, cr
22
22
  import { explainChart, getCapabilities as discoverCapabilities, getChartCapability, getPreferenceCapabilities, planChart } from './capabilities.mjs';
23
23
  import { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, normalizePreferences, validatePreferences } from './preferences.mjs';
24
24
  import { mountChartSettings } from './preferences-ui.mjs';
25
+ import { ChartValidationError } from './errors.mjs';
26
+ import { diagramOperations } from './contract-registry.mjs';
27
+ import { destroyChart } from './chart-lifecycle.mjs';
25
28
 
26
29
  import { isDiagram, paintSelection, diagramPointer, diagramKeyboard } from './diagram-interaction.mjs';
27
30
 
@@ -189,7 +192,7 @@ export class Chart {
189
192
  delete specInput.preferencesStore;
190
193
  delete specInput.chartId;
191
194
  const result = validateSpec(specInput);
192
- if (!result.valid) { const error = new Error(result.errors.map(item => item.message).join(' ')); error.details = result.errors; throw error; }
195
+ if (!result.valid) throw new ChartValidationError('create', result.errors);
193
196
  this._specDiagnostics = { warnings: result.warnings, normalizations: result.normalizations };
194
197
  this.chartId = input.chartId ?? input.id ?? null;
195
198
  this._preferencesStore = isPreferencesStore(input.preferencesStore) ? input.preferencesStore : isPreferencesStore(input.preferences) ? input.preferences : null;
@@ -209,6 +212,7 @@ export class Chart {
209
212
  this._history = new EditHistory();
210
213
  this._revision = 0;
211
214
  this._lastChangeSet = null;
215
+ this._destroyed = false;
212
216
  this._editor = new EditController(this);
213
217
  this.plugins = new PluginHost(this, this.spec.plugins);
214
218
  this._mountRenderer();
@@ -332,16 +336,114 @@ export class Chart {
332
336
  _updateCrosshair(x, y) { const vertical = this.model.scene.find('crosshair-x'), horizontal = this.model.scene.find('crosshair-y'); if (!vertical || !horizontal) return; vertical.geometry.x1 = vertical.geometry.x2 = x; vertical.style.opacity = 0.7; horizontal.geometry.y1 = horizontal.geometry.y2 = y; horizontal.style.opacity = 0.7; this.renderer.render(this.model.scene); }
333
337
  _showTooltip(payload) { if (typeof document === 'undefined' || !this.container || !payload.datum) return; let tip = this._tooltip; if (!tip) { tip = this._tooltip = document.createElement('div'); tip.className = 'ichart-v2-tooltip'; Object.assign(tip.style, { position: 'fixed', pointerEvents: 'none', zIndex: 9999, padding: '8px 10px', borderRadius: '6px', maxWidth: 'min(320px, calc(100vw - 24px))', whiteSpace: 'pre-line' }); document.body.appendChild(tip); } Object.assign(tip.style, { background: this.spec.theme.surface, color: this.spec.theme.text, border: `1px solid ${this.spec.theme.border}`, font: this.spec.theme.typography.tooltip.font, boxShadow: `0 4px 12px ${this.spec.theme.border}88` }); const text = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(this.spec.type) ? projectTooltip(this.spec.type, payload.datum) : Object.entries(payload.datum).map(([key, value]) => `${key}: ${value}`).join(' · '); tip.textContent = text; const left = Math.min((payload.nativeEvent.clientX || 0) + 12, window.innerWidth - tip.offsetWidth - 12); const top = Math.min((payload.nativeEvent.clientY || 0) + 12, window.innerHeight - tip.offsetHeight - 12); tip.style.left = `${Math.max(12, left)}px`; tip.style.top = `${Math.max(12, top)}px`; }
334
338
  _hideTooltip() { if (this._tooltip) this._tooltip.style.left = '-10000px'; }
339
+ _validateMutation(operation, input) {
340
+ const result = validateSpec(input);
341
+ if (!result.valid) throw new ChartValidationError(operation, result.errors);
342
+ return result;
343
+ }
344
+ _snapshotMutation() {
345
+ return {
346
+ spec: this.spec,
347
+ model: this.model,
348
+ themeInput: clone(this._themeInput),
349
+ styleOverrides: clone(this._styleOverrides),
350
+ preferenceBase: clone(this._preferenceBase),
351
+ localPreferences: clone(this._localPreferences),
352
+ preferencesSnapshot: this._preferencesSnapshot,
353
+ specDiagnostics: clone(this._specDiagnostics)
354
+ };
355
+ }
356
+ _restoreMutation(snapshot) {
357
+ this.spec = snapshot.spec;
358
+ this.model = snapshot.model;
359
+ this._themeInput = snapshot.themeInput;
360
+ this._styleOverrides = snapshot.styleOverrides;
361
+ this._preferenceBase = snapshot.preferenceBase;
362
+ this._localPreferences = snapshot.localPreferences;
363
+ this._preferencesSnapshot = snapshot.preferencesSnapshot;
364
+ this._specDiagnostics = snapshot.specDiagnostics;
365
+ if (this.renderer) this.renderer.options = this.spec;
366
+ try { if (this.renderer?.container && this.model?.scene) this.renderer.render(this.model.scene); } catch {}
367
+ }
368
+ _commitMutation() {
369
+ const previousRevision = this._revision;
370
+ this._revision += 1;
371
+ try {
372
+ this.render();
373
+ this._history.clear();
374
+ this._lastChangeSet = null;
375
+ this._editor.pending.clear();
376
+ return this;
377
+ } catch (error) {
378
+ this._revision = previousRevision;
379
+ throw error;
380
+ }
381
+ }
335
382
  on(type, listener) { if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type).add(listener); return this; }
336
383
  off(type, listener) { this.listeners.get(type)?.delete(listener); return this; }
337
384
  emit(type, event) { this.listeners.get(type)?.forEach(listener => listener(event)); }
338
- update(next = {}) { this._editor.invalidate(); const { preferences, preferencesStore, chartId, ...specUpdate } = next || {}; if (preferences !== undefined) this.setPreferences(preferences); if (specUpdate.theme !== undefined) this._themeInput = specUpdate.theme; ['colors', 'background', 'padding', 'legend', 'labels', 'grid', 'branding'].forEach(key => { if (specUpdate[key] !== undefined) this._styleOverrides[key] = true; }); this.spec = normalizeSpec({ ...this.spec, ...specUpdate, theme: this._themeInput, data: specUpdate.data === undefined ? this.spec.data : specUpdate.data }); ['legend', 'labels', 'grid', 'branding', 'padding'].forEach(key => { if (specUpdate[key] !== undefined) this._preferenceBase[key] = clone(this.spec[key]); }); this._resolveStyle(); return this.render(); }
339
- setData(data) { this._editor.invalidate(); this.spec = normalizeSpec({ ...this.spec, theme: this._themeInput, data: { values: data } }); this._resolveStyle(); return this.render(); }
340
- setTheme(theme = 'auto') { this._themeInput = theme; this._resolveStyle(); this.render(); this.emit('themechange', { chart: this, theme: this.getTheme(), source: 'user' }); return this; }
385
+ update(next = {}) {
386
+ const { preferences, preferencesStore, chartId, preferenceScope, ...specUpdate } = next || {};
387
+ if (preferences !== undefined) {
388
+ const checked = validatePreferences(preferences, { partial: true });
389
+ if (!checked.valid) throw new ChartValidationError('update', checked.errors);
390
+ }
391
+ const themeInput = specUpdate.theme === undefined ? this._themeInput : specUpdate.theme;
392
+ const result = this._validateMutation('update', { ...this.spec, ...specUpdate, theme: themeInput, data: specUpdate.data === undefined ? this.spec.data : specUpdate.data });
393
+ const snapshot = this._snapshotMutation();
394
+ try {
395
+ this._themeInput = themeInput;
396
+ ['colors', 'background', 'padding', 'legend', 'labels', 'grid', 'branding'].forEach(key => { if (specUpdate[key] !== undefined) this._styleOverrides[key] = true; });
397
+ this.spec = result.spec;
398
+ ['legend', 'labels', 'grid', 'branding', 'padding'].forEach(key => { if (specUpdate[key] !== undefined) this._preferenceBase[key] = clone(this.spec[key]); });
399
+ this._specDiagnostics = { warnings: result.warnings, normalizations: result.normalizations };
400
+ if (preferences !== undefined) {
401
+ if (this._preferencesStore) {
402
+ const scope = preferenceScope === 'global' ? 'global' : 'chart';
403
+ if (scope === 'global') this._preferencesStore.setGlobal(preferences, { source: 'update' });
404
+ else this._preferencesStore.setChart(this.chartId || 'default', preferences, { source: 'update' });
405
+ } else this._localPreferences = mergePreferences(this._localPreferences, preferences);
406
+ }
407
+ this._resolveStyle();
408
+ return this._commitMutation();
409
+ } catch (error) {
410
+ this._restoreMutation(snapshot);
411
+ throw error;
412
+ }
413
+ }
414
+ setData(data) {
415
+ const result = this._validateMutation('setData', { ...this.spec, theme: this._themeInput, data: { values: data } });
416
+ const snapshot = this._snapshotMutation();
417
+ try {
418
+ this.spec = result.spec;
419
+ this._specDiagnostics = { warnings: result.warnings, normalizations: result.normalizations };
420
+ this._resolveStyle();
421
+ return this._commitMutation();
422
+ } catch (error) {
423
+ this._restoreMutation(snapshot);
424
+ throw error;
425
+ }
426
+ }
427
+ setTheme(theme = 'auto') {
428
+ const result = this._validateMutation('setTheme', { ...this.spec, theme });
429
+ const snapshot = this._snapshotMutation();
430
+ try {
431
+ this._themeInput = theme;
432
+ this.spec = result.spec;
433
+ this._specDiagnostics = { warnings: result.warnings, normalizations: result.normalizations };
434
+ this._resolveStyle();
435
+ this._commitMutation();
436
+ this.emit('themechange', { chart: this, theme: this.getTheme(), source: 'user' });
437
+ return this;
438
+ } catch (error) {
439
+ this._restoreMutation(snapshot);
440
+ throw error;
441
+ }
442
+ }
341
443
  getTheme() { return clone(this.spec.theme); }
342
444
  getPreferences() { return this._preferencesStore ? this._preferencesStore.getEffective(this.chartId) : clone(this._localPreferences || defaultPreferences); }
343
- setPreferences(patch = {}, options = {}) { if (this._preferencesStore) { const scope = options.scope === 'global' ? 'global' : 'chart'; if (scope === 'global') this._preferencesStore.setGlobal(patch, { ...options, source: options.source || 'user' }); else this._preferencesStore.setChart(this.chartId || 'default', patch, { ...options, source: options.source || 'user' }); return this; } this._localPreferences = mergePreferences(this._localPreferences, patch); this._preferencesSnapshot = JSON.stringify(this._localPreferences); this._resolveStyle(); this.render(); this.emit('preferenceschange', { chart: this, preferences: this.getPreferences(), source: options.source || 'user', scope: 'chart', persisted: false }); return this; }
344
- resetPreferences(options = {}) { if (this._preferencesStore) { this._preferencesStore.reset({ ...options, scope: options.scope || 'chart', chartId: this.chartId || 'default' }); return this; } this._localPreferences = normalizePreferences({}); this._preferencesSnapshot = JSON.stringify(this._localPreferences); this._resolveStyle(); this.render(); this.emit('preferenceschange', { chart: this, preferences: this.getPreferences(), source: options.source || 'user', scope: 'chart', persisted: false }); return this; }
445
+ setPreferences(patch = {}, options = {}) { const checked = validatePreferences(patch, { partial: true }); if (!checked.valid) throw new ChartValidationError('setPreferences', checked.errors); if (this._preferencesStore) { const scope = options.scope === 'global' ? 'global' : 'chart'; if (scope === 'global') this._preferencesStore.setGlobal(checked.value, { ...options, source: options.source || 'user' }); else this._preferencesStore.setChart(this.chartId || 'default', checked.value, { ...options, source: options.source || 'user' }); return this; } const snapshot = this._snapshotMutation(); try { this._localPreferences = mergePreferences(this._localPreferences, checked.value); this._preferencesSnapshot = JSON.stringify(this._localPreferences); this._resolveStyle(); this._commitMutation(); this.emit('preferenceschange', { chart: this, preferences: this.getPreferences(), source: options.source || 'user', scope: 'chart', persisted: false }); return this; } catch (error) { this._restoreMutation(snapshot); throw error; } }
446
+ resetPreferences(options = {}) { if (this._preferencesStore) { this._preferencesStore.reset({ ...options, scope: options.scope || 'chart', chartId: this.chartId || 'default' }); return this; } const snapshot = this._snapshotMutation(); try { this._localPreferences = normalizePreferences({}); this._preferencesSnapshot = JSON.stringify(this._localPreferences); this._resolveStyle(); this._commitMutation(); this.emit('preferenceschange', { chart: this, preferences: this.getPreferences(), source: options.source || 'user', scope: 'chart', persisted: false }); return this; } catch (error) { this._restoreMutation(snapshot); throw error; } }
345
447
  resize(width = this.spec.width, height = this.spec.height) { this.spec.width = width; this.spec.height = height; this.render(); this.emit('resize', { chart: this, width, height }); return this; }
346
448
  getSpec() { return JSON.parse(JSON.stringify(this.spec)); }
347
449
  _getDiagnostics() { return [...new Map([...(this._specDiagnostics?.warnings || []), ...(this.model.data?.warnings || []), ...(this.spec.theme?.warnings || [])].map(item => [`${item.code}:${item.path || ''}`, item])).values()]; }
@@ -447,7 +549,36 @@ export class Chart {
447
549
  getElementAt(x, y) { return this.model.scene.hit(x, y); }
448
550
  selectBox(start, end) { const left = Math.min(start.x, end.x), right = Math.max(start.x, end.x), top = Math.min(start.y, end.y), bottom = Math.max(start.y, end.y); this._selected.clear(); this.model.scene.walk(node => { node.selected = false; if (!node.dataRef || !node.bounds || !node.interactive || (isDiagram(this) && !node.id.startsWith('node-'))) return; const intersects = node.bounds.x <= right && node.bounds.x + node.bounds.width >= left && node.bounds.y <= bottom && node.bounds.y + node.bounds.height >= top; if (intersects) { node.selected = true; this._selected.set(node.id, node.dataRef); } }); this.emit('selectionchange', { chart: this, selectedData: this.getSelectedData(), box: { start, end } }); this.render(); return this; }
449
551
  toDataTable() { return this.model.data.rows.map(row => ({ ...row })); }
450
- applyPatch(patches = []) { patches.forEach(patch => { const path = patch.path.replace(/^\//, '').split('/'); let target = this.spec; path.slice(0, -1).forEach(key => { target = target[key]; }); if (patch.op === 'remove') delete target[path.at(-1)]; else target[path.at(-1)] = patch.value; }); return this.render(); }
552
+ applyPatch(patches = []) {
553
+ const candidate = clone(this.spec);
554
+ try {
555
+ patches.forEach((patch, index) => {
556
+ if (!patch || !['add', 'replace', 'remove'].includes(patch.op) || typeof patch.path !== 'string' || !patch.path.startsWith('/')) throw new ChartValidationError('applyPatch', [{ code: 'INVALID_PATCH', path: `patches[${index}]`, message: 'Patch requires add, replace, or remove and an absolute JSON pointer path.', suggestion: 'Use for example { op: "replace", path: "/title/text", value: "Revenue" }.' }]);
557
+ const path = patch.path.replace(/^\//, '').split('/').map(key => key.replace(/~1/g, '/').replace(/~0/g, '~'));
558
+ let target = candidate;
559
+ path.slice(0, -1).forEach(key => { if (target == null || typeof target !== 'object' || !(key in target)) throw new ChartValidationError('applyPatch', [{ code: 'INVALID_PATCH_PATH', path: `patches[${index}].path`, message: `Patch path does not exist: ${patch.path}`, suggestion: 'Patch an existing Spec path or add a direct child of an existing object.' }]); target = target[key]; });
560
+ const key = path.at(-1);
561
+ if (patch.op === 'remove') delete target[key];
562
+ else target[key] = clone(patch.value);
563
+ });
564
+ } catch (error) {
565
+ if (error instanceof ChartValidationError) throw error;
566
+ throw new ChartValidationError('applyPatch', [{ code: 'INVALID_PATCH', path: 'patches', message: error.message, suggestion: 'Use JSON-safe patch values and valid Spec paths.' }]);
567
+ }
568
+ const themePatched = patches.some(patch => typeof patch?.path === 'string' && (patch.path === '/theme' || patch.path.startsWith('/theme/')));
569
+ const result = this._validateMutation('applyPatch', { ...candidate, theme: themePatched ? candidate.theme : this._themeInput });
570
+ const snapshot = this._snapshotMutation();
571
+ try {
572
+ this.spec = result.spec;
573
+ if (themePatched) this._themeInput = candidate.theme;
574
+ this._specDiagnostics = { warnings: result.warnings, normalizations: result.normalizations };
575
+ this._resolveStyle();
576
+ return this._commitMutation();
577
+ } catch (error) {
578
+ this._restoreMutation(snapshot);
579
+ throw error;
580
+ }
581
+ }
451
582
  resetZoom() { delete this.spec.view; return this.render(); }
452
583
  zoomTo(view) { this.spec.view = { ...view }; this.emit('zoomchange', { chart: this, view: this.spec.view }); return this.render(); }
453
584
  panBy(delta) { const current = this.spec.view || {}; this.spec.view = { ...current, offsetX: (current.offsetX || 0) + (delta.x || 0), offsetY: (current.offsetY || 0) + (delta.y || 0) }; this.emit('zoomchange', { chart: this, view: this.spec.view }); return this.render(); }
@@ -626,11 +757,12 @@ export class Chart {
626
757
  return { valid: false, code: 'DOWNLOAD_FAILED', message: String(err && err.message || err), suggestion: 'Try chart.export() and write the result manually.' };
627
758
  }
628
759
  }
629
- destroy() { this._preferencesUnsubscribe?.(); if (this._resizeObserver) this._resizeObserver.disconnect(); this._colorSchemeQuery?.removeEventListener?.('change', this._colorSchemeHandler); if (this._eventsBound) { const { target, handler, start, move, end, leave, touchStart, touchMove, touchEnd, wheel } = this._eventsBound; target.removeEventListener('mousemove', handler); target.removeEventListener('click', handler); target.removeEventListener('pointerdown', start); target.removeEventListener('pointermove', move); target.removeEventListener('pointerup', end); target.removeEventListener('pointercancel', end); target.removeEventListener('pointerleave', leave); target.removeEventListener('touchstart', touchStart); target.removeEventListener('touchmove', touchMove); target.removeEventListener('touchend', touchEnd); target.removeEventListener('wheel', wheel); } const target = this.renderer.svg || this.renderer.canvas; if (this._keyboardHandler) target?.removeEventListener('keydown', this._keyboardHandler); this._tooltip?.remove(); this.plugins.destroy(); this.renderer.destroy(); this.listeners.clear(); this._eventsBound = null; this._selected.clear(); this._clipboard = { nodes: [], edges: [] }; }
760
+ destroy() { destroyChart(this); }
630
761
  }
631
762
 
632
763
  export function createChart(spec) { return new Chart(spec); }
633
- export function getCapabilities() { return { ...discoverCapabilities(), interactionDefaults: { zoom: false, pan: false, brush: false, drag: false, edgeDrag: false, portConnect: false, editing: false }, diagram: { layoutModes: diagramLayoutModes, routingModes: edgeRoutingModes, entities: ['node', 'edge', 'lane', 'group', 'port', 'waypoint'], operations: ['moveNode', 'moveNodes', 'resizeNode', 'alignNodes', 'snapNodes', 'moveNodeToLane', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'removeEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'], curvedEdges: { renderers: ['canvas', 'svg'], type: 'cubic-bezier', tension: { minimum: 0.2, maximum: 0.8, default: 0.4 }, mindmapDefault: true, obstacleFallback: 'orthogonal', controlPointEditing: false }, edgeEditing: { renderers: ['canvas', 'svg'], selection: true, waypointDrag: true, segmentDrag: true, persistentWaypoints: true, enabledByDefault: false, activation: ['editing.enabled', 'interaction.edgeDrag'] }, validation: ['duplicate-ids', 'missing-endpoints', 'missing-ports', 'missing-lanes', 'invalid-waypoints', 'invalid-curve-tension'] }, editing: { modes: ['preview', 'commit', 'undo', 'redo'], operations: ['updateField', 'updateRecord', 'updateTask', 'shiftTask', 'updateProgress', 'addDependency', 'removeDependency', 'updateMilestone', 'moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'removeEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'], confirmationRequiredByDefault: true, commit: 'local-runtime-host-persistence-required' } }; }
764
+ export { ChartValidationError } from './errors.mjs';
765
+ export function getCapabilities() { const capabilities = discoverCapabilities(); return { ...capabilities, interactionDefaults: { ...capabilities.interactionDefaults }, diagram: { layoutModes: diagramLayoutModes, routingModes: edgeRoutingModes, entities: ['node', 'edge', 'lane', 'group', 'port', 'waypoint'], operations: [...diagramOperations], curvedEdges: { renderers: ['canvas', 'svg'], type: 'cubic-bezier', tension: { minimum: 0.2, maximum: 0.8, default: 0.4 }, mindmapDefault: true, obstacleFallback: 'orthogonal', controlPointEditing: false }, edgeEditing: { renderers: ['canvas', 'svg'], selection: true, waypointDrag: true, segmentDrag: true, persistentWaypoints: true, enabledByDefault: false, activation: ['editing.enabled', 'interaction.edgeDrag'] }, validation: ['duplicate-ids', 'missing-endpoints', 'missing-ports', 'missing-lanes', 'invalid-waypoints', 'invalid-curve-tension'] }, editing: { modes: ['preview', 'commit', 'undo', 'redo'], operations: [...capabilities.commands], confirmationRequiredByDefault: true, commit: 'local-runtime-host-persistence-required' } }; }
634
766
  export function recommend(input, options = {}) { const plan = planChart(input, options); return { primary: plan.primary, alternatives: plan.alternatives, reason: plan.reasons.join(' '), reasons: plan.reasons, confidence: plan.confidence, requiredFields: plan.requiredFields, assumptions: plan.assumptions, warnings: plan.warnings, nextActions: plan.nextActions }; }
635
767
  export { normalizeSpec, validateSpec, normalizeData, inspectData, binData, applyTransforms, data, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, getChartCapability, getPreferenceCapabilities, planChart, explainChart };
636
768
  export { diagramLayoutModes, diagramModes, edgeRoutingModes, normalizeDiagramSpec, validateDiagram, layoutDiagram, routeEdge };
@@ -639,4 +771,4 @@ export { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, cr
639
771
 
640
772
  export { contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin };
641
773
  export { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, mountChartSettings, normalizePreferences, validatePreferences };
642
- export const iChart = { version: '2.0.11', createChart, inspectData, normalizeData, binData, applyTransforms, data, getCapabilities, getChartCapability, getPreferenceCapabilities, planChart, recommend, explainChart, contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, createPreferencesStore, defaultPreferences, normalizePreferences, mergePreferences, validatePreferences, applyPreferencesToSpec, mountChartSettings, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries, normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };
774
+ export const iChart = { version: '2.0.14', createChart, ChartValidationError, inspectData, normalizeData, binData, applyTransforms, data, getCapabilities, getChartCapability, getPreferenceCapabilities, planChart, recommend, explainChart, contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, createPreferencesStore, defaultPreferences, normalizePreferences, mergePreferences, validatePreferences, applyPreferencesToSpec, mountChartSettings, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries, normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };
package/src/layout.mjs CHANGED
@@ -19,8 +19,33 @@ export function titleLayout(spec) {
19
19
  return { titleY, subtitleY, bottom };
20
20
  }
21
21
 
22
+ function characterWidth(character) {
23
+ if (/[\p{Mark}\u200d\ufe0e\ufe0f]/u.test(character)) return 0;
24
+ if (/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Extended_Pictographic}]/u.test(character)) return 1;
25
+ if (/\s/u.test(character)) return 0.33;
26
+ if (/[ilI1|.,:;'`!]/u.test(character)) return 0.32;
27
+ if (/[MW@#%&]/u.test(character)) return 0.82;
28
+ return 0.58;
29
+ }
30
+
22
31
  export function estimateTextWidth(text, size = 12) {
23
- return Math.max(size, String(text ?? '').length * size * 0.58);
32
+ const width = Array.from(String(text ?? '')).reduce((sum, character) => sum + characterWidth(character), 0) * size;
33
+ return Math.max(size, width);
34
+ }
35
+
36
+ export function truncateText(text, maxWidth, size = 12) {
37
+ const value = String(text ?? '');
38
+ if (!(maxWidth > 0) || estimateTextWidth(value, size) <= maxWidth) return value;
39
+ const suffix = '…', suffixWidth = estimateTextWidth(suffix, size);
40
+ if (suffixWidth > maxWidth) return '';
41
+ const characters = Array.from(value);
42
+ let low = 0, high = characters.length;
43
+ while (low < high) {
44
+ const middle = Math.ceil((low + high) / 2);
45
+ if (estimateTextWidth(`${characters.slice(0, middle).join('')}${suffix}`, size) <= maxWidth) low = middle;
46
+ else high = middle - 1;
47
+ }
48
+ return `${characters.slice(0, low).join('')}${suffix}`;
24
49
  }
25
50
 
26
51
  export function axisLabelLayout(spec, labels = [], availableWidth = 0) {
package/src/project.mjs CHANGED
@@ -4,19 +4,48 @@
4
4
  */
5
5
  import { Scene, cubicBezierPoint } from './scene.mjs';
6
6
  import { normalizeData } from './data.mjs';
7
- import { layoutDiagram, normalizeDiagramData, routeEdgePath } from './diagram.mjs';
7
+ import { layoutArchitectureNodes, 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, estimateTextWidth, fontSize, titleLayout } from './layout.mjs';
11
+ import { axisLabelLayout, estimateTextWidth, fontSize, titleLayout, truncateText } from './layout.mjs';
12
12
 
13
13
  export const projectTypes = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'];
14
14
  const day = 86400000;
15
15
  export const timestamp = value => value == null || value === '' ? NaN : new Date(value).getTime();
16
16
  const dateLabel = value => new Date(value).toISOString().slice(0, 10);
17
17
  const dependencyId = dependency => typeof dependency === 'string' ? dependency : dependency?.id || null;
18
+ const dependencyType = dependency => typeof dependency === 'object' && dependency?.type ? dependency.type : 'finish-to-start';
18
19
  const markText = (theme, background) => (contrastRatio(theme.text, background) || 0) >= (contrastRatio(theme.background, background) || 0) ? theme.text : theme.background;
19
20
 
21
+ function compactRoute(points) {
22
+ return points.filter((point, index) => {
23
+ const previous = points[index - 1], next = points[index + 1];
24
+ if (previous && point.x === previous.x && point.y === previous.y) return false;
25
+ return !(previous && next && (previous.x === point.x && point.x === next.x || previous.y === point.y && point.y === next.y));
26
+ });
27
+ }
28
+
29
+ function routeGanttDependency(dependency, from, to, positions, barOffset, plot) {
30
+ const type = dependencyType(dependency);
31
+ const sourceAtStart = type === 'start-to-start' || type === 'start-to-finish';
32
+ const targetAtEnd = type === 'finish-to-finish' || type === 'start-to-finish';
33
+ const source = { x: sourceAtStart ? from.start : from.end, y: from.y };
34
+ const target = { x: targetAtEnd ? to.end : to.start, y: to.y };
35
+ const gap = target.x - source.x;
36
+ if (type === 'finish-to-start' && gap > 4) {
37
+ const channelX = source.x + Math.min(12, gap / 2);
38
+ const top = Math.min(source.y, target.y), bottom = Math.max(source.y, target.y);
39
+ const blocked = [...positions.values()].some(position => position !== from && position !== to && position.y + barOffset >= top && position.y - barOffset <= bottom && channelX >= position.start - 2 && channelX <= position.end + 2);
40
+ if (!blocked) return compactRoute([source, { x: channelX, y: source.y }, { x: channelX, y: target.y }, target]);
41
+ }
42
+ const clampX = value => Math.max(plot.x, Math.min(plot.x + plot.width, value));
43
+ const sourceStubX = clampX(source.x + (sourceAtStart ? -12 : 12));
44
+ const targetStubX = clampX(target.x + (targetAtEnd ? 10 : -10));
45
+ const corridorY = Math.min(source.y, target.y) - Math.max(6, barOffset + 3);
46
+ return compactRoute([source, { x: sourceStubX, y: source.y }, { x: sourceStubX, y: corridorY }, { x: targetStubX, y: corridorY }, { x: targetStubX, y: target.y }, target]);
47
+ }
48
+
20
49
  function text(scene, id, content, x, y, style = {}) {
21
50
  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 });
22
51
  }
@@ -54,6 +83,12 @@ function projectLabelReserve(spec, rows) {
54
83
  return Math.min(170, Math.max(spec.padding.left, Math.ceil(widest + 20)));
55
84
  }
56
85
 
86
+ function architectureLabelReserve(spec) {
87
+ const labels = (spec.layers || spec.data?.layers || []).map(layer => layer.label || layer.id).filter(Boolean);
88
+ const widest = Math.max(0, ...labels.map(label => estimateTextWidth(label, fontSize(spec, 'legend', 12))));
89
+ return Math.min(170, Math.max(Math.min(110, spec.width * 0.2), Math.ceil(widest + 20)));
90
+ }
91
+
57
92
  function projectOverlays(spec) {
58
93
  const project = projectConfig(spec);
59
94
  return {
@@ -175,7 +210,7 @@ function tasksScene(scene, spec, rows, state) {
175
210
  });
176
211
  positions.set(row.id || recordId, { start, end, y });
177
212
  const label = row.name || row.title || row.label || row.id || `Item ${index + 1}`;
178
- text(scene, `project-label-${index}`, label.length > 17 ? `${label.slice(0, 16)}…` : label, plot.x - 12, y + 4, { textAnchor: 'end' });
213
+ text(scene, `project-label-${index}`, truncateText(label, Math.max(12, plot.x - 20), fontSize(spec, 'axis', 12)), plot.x - 12, y + 4, { textAnchor: 'end' });
179
214
  if (row.progress != null && !milestone) scene.add({ id: `project-progress-${index}`, type: 'rect', geometry: { ...geometry, width: geometry.width * (row.progress > 1 ? row.progress / 100 : row.progress) }, style: { fill: spec.theme.text, opacity: 0.25 }, zIndex: 3 });
180
215
  if (overlays.slack && analytics?.latestFinish && !milestone) {
181
216
  const latestEnd = map(analytics.latestFinish);
@@ -191,8 +226,7 @@ function tasksScene(scene, spec, rows, state) {
191
226
  const from = positions.get(fromId), to = positions.get(toId);
192
227
  if (!from || !to) return;
193
228
  const critical = highlighted.has(fromId) && highlighted.has(toId) && state.schedule?.criticalEdges?.some(edge => edge.from === fromId && edge.to === toId);
194
- const bend = from.end + 12, routeOffset = Math.max(6, barOffset + 3);
195
- arrow(scene, `dependency-${index}-${dependencyIndex}`, [{ x: from.end, y: from.y }, { x: bend, y: from.y }, { x: bend, y: to.y - routeOffset }, { x: to.start - 10, y: to.y - routeOffset }, { x: to.start - 10, y: to.y }, { x: to.start, y: to.y }], { from: fromId, to: toId, critical }, critical);
229
+ arrow(scene, `dependency-${index}-${dependencyIndex}`, routeGanttDependency(dependency, from, to, positions, barOffset, plot), { from: fromId, to: toId, type: dependencyType(dependency), critical }, critical);
196
230
  }));
197
231
  state.timeDomain = [min, max];
198
232
  }
@@ -228,6 +262,7 @@ function burndownScene(scene, spec, rows, state) {
228
262
  function diagramScene(scene, spec, rows, state) {
229
263
  const diagramSpec = normalizeDiagramData(spec);
230
264
  const plot = state.plot, mode = diagramSpec.diagram.mode, edges = diagramSpec.edges, lanes = diagramSpec.lanes, layers = diagramSpec.layers, groups = diagramSpec.groups, layout = layoutDiagram(diagramSpec, plot);
265
+ const architectureLayout = mode === 'architecture' && layers.length ? layoutArchitectureNodes(diagramSpec, plot) : {};
231
266
  rows = diagramSpec.nodes;
232
267
  const collapsedGroups = new Set(groups.filter(group => group.collapsed).map(group => group.id));
233
268
  const ranks = new Map(rows.map(row => [row.id, 0]));
@@ -246,8 +281,6 @@ function diagramScene(scene, spec, rows, state) {
246
281
  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 });
247
282
  text(scene, `lane-label-${index}`, lane.label || lane.id, plot.x - 12, plot.y + index * laneHeight + 20, { textAnchor: 'end' });
248
283
  });
249
- const layerSlots = new Map();
250
- if (mode === 'architecture' && layers.length) layers.forEach(layer => layerSlots.set(layer.id, 0));
251
284
  if (mode === 'architecture' && layers.length) layers.forEach((layer, index) => {
252
285
  const layerHeight = plot.height / layers.length;
253
286
  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 });
@@ -260,15 +293,11 @@ function diagramScene(scene, spec, rows, state) {
260
293
  slots.set(slotKey, slot + 1);
261
294
  const sameCell = rows.filter(item => ranks.get(item.id) === rank && (!lanes.length || item.laneId === row.laneId)).length;
262
295
  const generated = layout[row.id];
263
- const layerIndex = mode === 'architecture' && layers.length ? Math.max(0, layers.findIndex(layer => layer.id === row.layerId)) : -1;
264
- const layerHeight = layers.length ? plot.height / layers.length : plot.height;
265
- const nodeWidth = mode === 'architecture' && layers.length ? Math.min(112, Math.max(72, plot.width / layers.length - 4)) : 112;
266
- const nodeHeight = mode === 'architecture' && layers.length ? Math.min(36, Math.max(14, layerHeight - 6)) : 36;
267
296
  const nodeFont = mode === 'architecture' && state.compact ? '550 10px system-ui, sans-serif' : null;
268
- const layerSlot = layerIndex >= 0 ? layerSlots.get(row.layerId) : 0;
269
- if (layerIndex >= 0) layerSlots.set(row.layerId, layerSlot + 1);
270
- const layerCount = layerIndex >= 0 ? rows.filter(item => item.layerId === row.layerId).length : 1;
271
- const geometry = { x: row.position?.x ?? (layerIndex >= 0 ? plot.x + (layerIndex + 0.5) * plot.width / layers.length - nodeWidth / 2 : 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) - nodeHeight / 2 : 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 || nodeWidth, height: row.size?.height || nodeHeight };
297
+ const architectureBox = architectureLayout[row.id];
298
+ const geometry = architectureBox
299
+ ? { ...architectureBox, x: row.position?.x ?? architectureBox.x, y: row.position?.y ?? architectureBox.y }
300
+ : { 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 };
272
301
  positions.set(row.id, geometry);
273
302
  if (collapsedGroups.has(row.groupId)) return;
274
303
  const depth = row.parentId ? (ranks.get(row.id) || 0) : 0;
@@ -341,7 +370,7 @@ export function buildProjectScene(spec) {
341
370
  const data = { ...normalizeData(visibleRows), rows: visibleRows.map(row => ({ ...row })), sourceRows: sourceRows.map(row => ({ ...row })) };
342
371
  const scene = new Scene(spec.width, spec.height);
343
372
  scene.theme = spec.theme;
344
- const left = spec.type === 'architecture' ? Math.min(110, spec.width * 0.2) : ['gantt', 'timeline', 'milestone', 'swimlane'].includes(spec.type) ? projectLabelReserve(spec, sourceRows) : spec.padding.left;
373
+ const left = spec.type === 'architecture' ? architectureLabelReserve(spec) : ['gantt', 'timeline', 'milestone', 'swimlane'].includes(spec.type) ? projectLabelReserve(spec, sourceRows) : spec.padding.left;
345
374
  const title = titleLayout(spec);
346
375
  const plotTop = Math.max(spec.padding.top, title.bottom + (title.bottom ? 12 : 0)) + (spec.type === 'burndown' ? 16 : 0);
347
376
  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);
package/src/schema.mjs CHANGED
@@ -87,6 +87,7 @@ const models = {
87
87
  swimlane: schema('swimlane', { label: { type: 'string', required: true, editable: true } }),
88
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
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-edge': schema('mindmap-edge', { from: { type: 'string', required: true, editable: true }, to: { type: 'string', required: true, editable: true }, label: { 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
91
  '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 })
91
92
  };
92
93