@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.
- package/CHANGELOG.md +17 -0
- package/README.md +2 -2
- package/docs/agent/charting-scenario.md +3 -1
- package/docs/agent/coding-agent-integration.md +1 -1
- package/docs/agent/development/2.0-release-readiness.md +4 -0
- package/docs/agent/development/iteration-13.md +278 -0
- package/docs/agent/development/release-sop.md +1 -1
- package/docs/agent/development/roadmap.md +2 -1
- package/docs/agent/diagram-scenario.md +4 -0
- package/docs/agent/editing-contract.md +3 -0
- package/docs/agent/frontend-integration.md +1 -1
- package/docs/agent/project-scenario.md +3 -0
- package/docs/agent/runtime-contract.md +4 -0
- package/docs/agent/usage-scenarios.md +1 -1
- package/docs/agent/zh-CN/charting-scenario.md +3 -1
- package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
- package/docs/agent/zh-CN/diagram-scenario.md +17 -1
- package/docs/agent/zh-CN/editing-contract.md +2 -0
- package/docs/agent/zh-CN/frontend-integration.md +1 -1
- package/docs/agent/zh-CN/project-scenario.md +4 -2
- package/docs/agent/zh-CN/runtime-contract.md +3 -1
- package/docs/agent/zh-CN/usage-scenarios.md +1 -1
- package/docs/manifests/capabilities.json +1310 -32
- package/docs/manifests/commands.json +7 -7
- package/docs/manifests/schemas.json +182 -10
- package/package.json +24 -4
- package/skills/ichartjs/SKILL.md +1 -1
- package/src/capabilities.mjs +10 -36
- package/src/chart-lifecycle.mjs +31 -0
- package/src/charts.mjs +49 -19
- package/src/command.mjs +3 -2
- package/src/contract-registry.mjs +59 -0
- package/src/diagram.mjs +30 -0
- package/src/edit-controller.mjs +1 -1
- package/src/errors.mjs +31 -0
- package/src/index.mjs +142 -10
- package/src/layout.mjs +26 -1
- package/src/project.mjs +45 -16
- package/src/schema.mjs +1 -0
- package/src/spec.mjs +45 -3
- package/types/consumer-fixture.ts +35 -0
- 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); });
|
package/src/edit-controller.mjs
CHANGED
|
@@ -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)
|
|
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 = {}) {
|
|
339
|
-
|
|
340
|
-
|
|
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(
|
|
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.
|
|
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 = []) {
|
|
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() {
|
|
760
|
+
destroy() { destroyChart(this); }
|
|
630
761
|
}
|
|
631
762
|
|
|
632
763
|
export function createChart(spec) { return new Chart(spec); }
|
|
633
|
-
export
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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' ?
|
|
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
|
|