@taylorwong/ichartjs 2.0.6 → 2.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +2 -2
- package/agent-recipes/minimal-specs.json +25 -0
- package/docs/agent/README.md +3 -1
- package/docs/agent/charting-scenario.md +46 -1
- package/docs/agent/coding-agent-integration.md +1 -1
- package/docs/agent/development/iteration-12.md +101 -0
- package/docs/agent/development/roadmap.md +2 -1
- package/docs/agent/development-guide.md +1 -1
- package/docs/agent/diagram-scenario.md +39 -11
- package/docs/agent/editing-contract.md +5 -0
- package/docs/agent/frontend-integration.md +1 -1
- package/docs/agent/project-scenario.md +1 -0
- package/docs/agent/quickstart.md +30 -0
- package/docs/agent/runtime-contract.md +19 -2
- package/docs/agent/theme-guide.md +33 -1
- package/docs/agent/usage-scenarios.md +3 -1
- package/docs/agent/zh-CN/README.md +1 -1
- package/docs/agent/zh-CN/charting-scenario.md +31 -1
- package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
- package/docs/agent/zh-CN/diagram-scenario.md +32 -3
- package/docs/agent/zh-CN/editing-contract.md +2 -0
- package/docs/agent/zh-CN/frontend-integration.md +1 -1
- package/docs/agent/zh-CN/iteration-12.md +98 -0
- package/docs/agent/zh-CN/quickstart.md +30 -0
- package/docs/agent/zh-CN/runtime-contract.md +7 -1
- package/docs/agent/zh-CN/theme-guide.md +33 -1
- package/docs/agent/zh-CN/usage-scenarios.md +3 -1
- package/docs/manifests/capabilities.json +42 -3
- package/docs/manifests/commands.json +10 -9
- package/docs/manifests/schemas.json +5 -2
- package/examples/agent-workflow.mjs +2 -1
- package/package.json +1 -1
- package/skills/ichartjs/SKILL.md +19 -9
- package/skills/ichartjs/references/agent-contract.md +5 -2
- package/skills/ichartjs/references/chart-selection.md +2 -0
- package/src/capabilities.mjs +83 -13
- package/src/charts.mjs +202 -58
- package/src/command.mjs +4 -3
- package/src/diagram-interaction.mjs +55 -11
- package/src/diagram.mjs +149 -36
- package/src/edit-controller.mjs +6 -3
- package/src/edit.mjs +17 -7
- package/src/index.mjs +44 -17
- package/src/layout.mjs +39 -0
- package/src/preferences-ui.mjs +73 -24
- package/src/project.mjs +64 -24
- package/src/renderer.mjs +2 -2
- package/src/scene.mjs +32 -1
- package/src/schema.mjs +7 -2
- package/src/spec.mjs +88 -6
- package/src/theme.mjs +1 -1
- package/types/index.d.ts +28 -7
package/src/project.mjs
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
* Project-management and process visualization scene builders.
|
|
3
3
|
* Covers Gantt, timeline, milestone, burndown, flow, and swimlane views.
|
|
4
4
|
*/
|
|
5
|
-
import { Scene } from './scene.mjs';
|
|
5
|
+
import { Scene, cubicBezierPoint } from './scene.mjs';
|
|
6
6
|
import { normalizeData } from './data.mjs';
|
|
7
|
-
import { layoutDiagram, normalizeDiagramData,
|
|
7
|
+
import { layoutDiagram, normalizeDiagramData, routeEdgePath } from './diagram.mjs';
|
|
8
8
|
import { analyzeBurndownSeries, analyzeSchedule } from './project-analytics.mjs';
|
|
9
9
|
import { createLinkedProjectState, filterProjectRows, linkedRecordId } from './project-linking.mjs';
|
|
10
10
|
import { contrastRatio, resolveTheme } from './theme.mjs';
|
|
11
|
+
import { axisLabelLayout, titleLayout } from './layout.mjs';
|
|
11
12
|
|
|
12
|
-
export const projectTypes = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane'];
|
|
13
|
+
export const projectTypes = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'];
|
|
13
14
|
const day = 86400000;
|
|
14
15
|
export const timestamp = value => value == null || value === '' ? NaN : new Date(value).getTime();
|
|
15
16
|
const dateLabel = value => new Date(value).toISOString().slice(0, 10);
|
|
@@ -20,21 +21,22 @@ function text(scene, id, content, x, y, style = {}) {
|
|
|
20
21
|
scene.add({ id, type: 'text', geometry: { text: String(content), x, y }, style: { fill: scene.theme?.text || '#334155', font: scene.theme?.typography?.label?.font || '12px system-ui', ...style }, zIndex: 4 });
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
function arrow(scene, id, points, reference, critical = false) {
|
|
24
|
+
function arrow(scene, id, points, reference, critical = false, curve = null) {
|
|
24
25
|
const color = critical ? scene.theme?.status?.danger || '#dc2626' : scene.theme?.axis || '#64748b';
|
|
25
26
|
const tip = points.at(-1), previous = points.at(-2);
|
|
26
27
|
const angle = Math.atan2(tip.y - previous.y, tip.x - previous.x), size = 7;
|
|
27
|
-
scene.add({ id, type: 'path', geometry: { points }, style: { fill: 'none', stroke: color, strokeWidth: critical ? 2.5 : 1.5 }, dataRef: reference, zIndex: 1 });
|
|
28
|
+
scene.add({ id, type: 'path', geometry: { points, ...(curve ? { curve } : {}) }, style: { fill: 'none', stroke: color, strokeWidth: critical ? 2.5 : 1.5 }, dataRef: reference, interactive: Boolean(reference?.edgeId), hitTolerance: 8, zIndex: 1 });
|
|
28
29
|
scene.add({ id: id.startsWith('dependency-') ? id.replace('dependency-', 'dependency-arrow-') : `${id}-arrow`, type: 'path', geometry: { points: [tip, { x: tip.x - size * Math.cos(angle - Math.PI / 6), y: tip.y - size * Math.sin(angle - Math.PI / 6) }, { x: tip.x - size * Math.cos(angle + Math.PI / 6), y: tip.y - size * Math.sin(angle + Math.PI / 6) }, tip] }, style: { fill: color, stroke: color }, zIndex: 3 });
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
function timeAxis(scene, plot, min, max) {
|
|
32
33
|
const map = value => plot.x + (timestamp(value) - min) / (max - min || day) * plot.width;
|
|
33
34
|
const ticks = Math.max(2, Math.min(5, Math.floor(plot.width / 100)));
|
|
35
|
+
const labelLayout = scene.xLabelLayout || axisLabelLayout({ theme: scene.theme }, Array(ticks).fill('2000-00-00'), plot.width);
|
|
34
36
|
for (let index = 0; index < ticks; index += 1) {
|
|
35
37
|
const time = min + (max - min) * index / (ticks - 1), x = map(time);
|
|
36
38
|
scene.add({ id: `project-grid-${index}`, type: 'line', geometry: { x1: x, y1: plot.y, x2: x, y2: plot.y + plot.height }, style: { stroke: scene.theme?.grid || '#e2e8f0' } });
|
|
37
|
-
text(scene, `project-tick-${index}`, dateLabel(time), x, plot.y + plot.height + 22, { fill: scene.theme?.muted, textAnchor: index === 0 ? 'start' : index === ticks - 1 ? 'end' : 'middle', font: scene.theme?.typography?.axis?.font || '11px system-ui' });
|
|
39
|
+
text(scene, `project-tick-${index}`, dateLabel(time), x, plot.y + plot.height + (labelLayout.rotation ? 10 : 22), { fill: scene.theme?.muted, textAnchor: labelLayout.rotation ? 'end' : index === 0 ? 'start' : index === ticks - 1 ? 'end' : 'middle', textBaseline: labelLayout.rotation ? 'middle' : 'alphabetic', rotation: labelLayout.rotation, font: scene.theme?.typography?.axis?.font || '11px system-ui' });
|
|
38
40
|
}
|
|
39
41
|
return map;
|
|
40
42
|
}
|
|
@@ -216,7 +218,7 @@ function burndownScene(scene, spec, rows, state) {
|
|
|
216
218
|
|
|
217
219
|
function diagramScene(scene, spec, rows, state) {
|
|
218
220
|
const diagramSpec = normalizeDiagramData(spec);
|
|
219
|
-
const plot = state.plot, edges = diagramSpec.edges, lanes = diagramSpec.lanes, groups = diagramSpec.groups, layout = layoutDiagram(diagramSpec, plot);
|
|
221
|
+
const plot = state.plot, mode = diagramSpec.diagram.mode, edges = diagramSpec.edges, lanes = diagramSpec.lanes, layers = diagramSpec.layers, groups = diagramSpec.groups, layout = layoutDiagram(diagramSpec, plot);
|
|
220
222
|
rows = diagramSpec.nodes;
|
|
221
223
|
const collapsedGroups = new Set(groups.filter(group => group.collapsed).map(group => group.id));
|
|
222
224
|
const ranks = new Map(rows.map(row => [row.id, 0]));
|
|
@@ -235,17 +237,31 @@ function diagramScene(scene, spec, rows, state) {
|
|
|
235
237
|
scene.add({ id: `lane-${index}`, type: 'rect', geometry: { x: plot.x, y: plot.y + index * laneHeight, width: Math.max(plot.width, columns * gapX), height: laneHeight }, style: { fill: index % 2 ? spec.theme.surface : spec.theme.background, stroke: spec.theme.border }, zIndex: -1 });
|
|
236
238
|
text(scene, `lane-label-${index}`, lane.label || lane.id, plot.x - 12, plot.y + index * laneHeight + 20, { textAnchor: 'end' });
|
|
237
239
|
});
|
|
240
|
+
const layerSlots = new Map();
|
|
241
|
+
if (mode === 'architecture' && layers.length) layers.forEach(layer => layerSlots.set(layer.id, 0));
|
|
242
|
+
if (mode === 'architecture' && layers.length) layers.forEach((layer, index) => {
|
|
243
|
+
const layerHeight = plot.height / layers.length;
|
|
244
|
+
scene.add({ id: `layer-${layer.id}`, type: 'rect', geometry: { x: plot.x, y: plot.y + index * layerHeight, width: plot.width, height: layerHeight }, style: { fill: index % 2 ? spec.theme.surface : spec.theme.background, stroke: spec.theme.border }, zIndex: -2 });
|
|
245
|
+
text(scene, `layer-label-${layer.id}`, layer.label || layer.id, plot.x - 12, plot.y + index * layerHeight + 20, { textAnchor: 'end', font: spec.theme.typography.legend.font });
|
|
246
|
+
});
|
|
238
247
|
rows.forEach((row, index) => {
|
|
239
248
|
const rank = ranks.get(row.id), lane = Math.max(0, lanes.findIndex(item => item.id === row.laneId));
|
|
240
249
|
const slotKey = `${lane}:${rank}`, slot = slots.get(slotKey) || 0;
|
|
241
250
|
slots.set(slotKey, slot + 1);
|
|
242
251
|
const sameCell = rows.filter(item => ranks.get(item.id) === rank && (!lanes.length || item.laneId === row.laneId)).length;
|
|
243
252
|
const generated = layout[row.id];
|
|
244
|
-
const
|
|
253
|
+
const layerIndex = mode === 'architecture' && layers.length ? Math.max(0, layers.findIndex(layer => layer.id === row.layerId)) : -1;
|
|
254
|
+
const layerHeight = layers.length ? plot.height / layers.length : plot.height;
|
|
255
|
+
const layerSlot = layerIndex >= 0 ? layerSlots.get(row.layerId) : 0;
|
|
256
|
+
if (layerIndex >= 0) layerSlots.set(row.layerId, layerSlot + 1);
|
|
257
|
+
const layerCount = layerIndex >= 0 ? rows.filter(item => item.layerId === row.layerId).length : 1;
|
|
258
|
+
const geometry = { x: row.position?.x ?? (layerIndex >= 0 ? plot.x + (layerIndex + 0.5) * plot.width / layers.length - 56 : generated?.x ?? plot.x + rank * gapX + 12), y: row.position?.y ?? (layerIndex >= 0 ? plot.y + layerIndex * layerHeight + (layerSlot + 0.5) * layerHeight / Math.max(1, layerCount) - 18 : generated?.y ?? plot.y + (lanes.length ? lane * laneHeight : 0) + (slot + 0.5) * (lanes.length ? laneHeight : Math.max(plot.height, sameCell * 64)) / sameCell - 18), width: row.size?.width || 112, height: row.size?.height || 36 };
|
|
245
259
|
positions.set(row.id, geometry);
|
|
246
260
|
if (collapsedGroups.has(row.groupId)) return;
|
|
247
|
-
|
|
248
|
-
|
|
261
|
+
const depth = row.parentId ? (ranks.get(row.id) || 0) : 0;
|
|
262
|
+
const colorIndex = mode === 'mindmap' ? depth % spec.colors.length : lane >= 0 ? lane % spec.colors.length : index % spec.colors.length;
|
|
263
|
+
scene.add({ id: `node-${row.id}`, type: 'rect', geometry, bounds: { ...geometry }, style: { fill: spec.colors[colorIndex], stroke: row.root || mode === 'mindmap' && !row.parentId ? spec.theme.focus : spec.theme.background, strokeWidth: row.root || mode === 'mindmap' && !row.parentId ? 2 : 1 }, dataRef: { nodeId: row.id, dataIndex: index, datum: row, groupId: row.groupId || null, layerId: row.layerId || null, parentId: row.parentId || null }, interactive: true, zIndex: 2 });
|
|
264
|
+
if (spec.editing?.enabled && spec.interaction?.portConnect) (row.ports || []).forEach(port => {
|
|
249
265
|
const point = port.side === 'left' ? { x: geometry.x, y: geometry.y + geometry.height * (port.offset ?? 0.5) } : port.side === 'top' ? { x: geometry.x + geometry.width * (port.offset ?? 0.5), y: geometry.y } : port.side === 'bottom' ? { x: geometry.x + geometry.width * (port.offset ?? 0.5), y: geometry.y + geometry.height } : { x: geometry.x + geometry.width, y: geometry.y + geometry.height * (port.offset ?? 0.5) };
|
|
250
266
|
scene.add({ id: `port-${row.id}-${port.id}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 6, y: point.y - 6, width: 12, height: 12 }, style: { fill: spec.theme.background, stroke: spec.theme.text, strokeWidth: 1.5 }, dataRef: { nodeId: row.id, portId: port.id, groupId: row.groupId || null }, interactive: true, zIndex: 4 });
|
|
251
267
|
});
|
|
@@ -264,6 +280,15 @@ function diagramScene(scene, spec, rows, state) {
|
|
|
264
280
|
const label = scene.find(`group-label-${group.id}`);
|
|
265
281
|
if (label) { label.bounds = { x: left, y: top, width: right - left, height: 20 }; label.dataRef = { groupId: group.id, collapsed: Boolean(group.collapsed) }; label.interactive = true; }
|
|
266
282
|
});
|
|
283
|
+
if (mode === 'architecture') {
|
|
284
|
+
diagramSpec.boundaries.forEach((boundary, boundaryIndex) => {
|
|
285
|
+
const boxes = rows.filter(row => (boundary.nodeIds || []).includes(row.id)).map(row => positions.get(row.id)).filter(Boolean);
|
|
286
|
+
if (!boxes.length) return;
|
|
287
|
+
const padding = Number(boundary.padding) || 16, left = Math.min(...boxes.map(box => box.x)) - padding, top = Math.min(...boxes.map(box => box.y)) - padding, right = Math.max(...boxes.map(box => box.x + box.width)) + padding, bottom = Math.max(...boxes.map(box => box.y + box.height)) + padding;
|
|
288
|
+
scene.add({ id: `boundary-${boundary.id || boundaryIndex}`, type: 'rect', geometry: { x: left, y: top, width: right - left, height: bottom - top }, bounds: { x: left, y: top, width: right - left, height: bottom - top }, style: { fill: 'none', stroke: boundary.color || spec.theme.focus, strokeWidth: 1.5, opacity: 0.8 }, dataRef: { boundaryId: boundary.id || `boundary-${boundaryIndex}` }, interactive: false, zIndex: -1 });
|
|
289
|
+
text(scene, `boundary-label-${boundary.id || boundaryIndex}`, boundary.label || boundary.id || `Boundary ${boundaryIndex + 1}`, left + 8, top + 15, { font: spec.theme.typography.legend.font, fill: boundary.color || spec.theme.focus });
|
|
290
|
+
});
|
|
291
|
+
}
|
|
267
292
|
edges.forEach((edge, index) => {
|
|
268
293
|
const fromNode = rows.find(row => row.id === edge.from), toNode = rows.find(row => row.id === edge.to);
|
|
269
294
|
if (!fromNode || !toNode) return;
|
|
@@ -272,10 +297,14 @@ function diagramScene(scene, spec, rows, state) {
|
|
|
272
297
|
const to = toNode.groupId && collapsedGroups.has(toNode.groupId) ? groupBoxes.get(toNode.groupId) : positions.get(edge.to);
|
|
273
298
|
if (!from || !to) return;
|
|
274
299
|
const obstacles = [...positions.entries()].filter(([id]) => id !== edge.from && id !== edge.to && !collapsedGroups.has(rows.find(row => row.id === id)?.groupId)).map(([, box]) => box);
|
|
275
|
-
const
|
|
276
|
-
|
|
300
|
+
const fromPortDefinition = fromNode.groupId && collapsedGroups.has(fromNode.groupId) ? null : fromNode?.ports?.find(port => port.id === edge.fromPort);
|
|
301
|
+
const toPortDefinition = toNode.groupId && collapsedGroups.has(toNode.groupId) ? null : toNode?.ports?.find(port => port.id === edge.toPort);
|
|
302
|
+
const routing = edge.routing || diagramSpec.diagram.routing;
|
|
303
|
+
const path = routeEdgePath({ ...edge, curveTension: edge.curveTension ?? diagramSpec.diagram.curveTension, grid: diagramSpec.diagram.grid, obstacles, fromPortDefinition, toPortDefinition }, from, to, routing);
|
|
304
|
+
const points = path.points;
|
|
305
|
+
arrow(scene, `edge-${index}`, points, { from: edge.from, to: edge.to, edgeId: edge.id || `edge-${index}`, status: edge.status, routing, curveTension: edge.curveTension ?? diagramSpec.diagram.curveTension, waypoints: edge.waypoints || [], fromPortDefinition, toPortDefinition, fromGroupId: fromNode.groupId || null, toGroupId: toNode.groupId || null }, edge.critical === true, path.curve);
|
|
277
306
|
if (edge.label) {
|
|
278
|
-
const middle = points[Math.floor(points.length / 2)];
|
|
307
|
+
const middle = path.curve === 'cubic' ? cubicBezierPoint(points, 0.5) : points[Math.floor(points.length / 2)];
|
|
279
308
|
text(scene, `edge-label-${index}`, edge.label, middle.x, middle.y - 8, { textAnchor: 'middle', font: spec.theme.typography.axis.font });
|
|
280
309
|
}
|
|
281
310
|
});
|
|
@@ -291,7 +320,7 @@ export function projectView(spec) {
|
|
|
291
320
|
export function buildProjectScene(spec) {
|
|
292
321
|
const theme = spec.theme && typeof spec.theme === 'object' && spec.theme.typography ? spec.theme : resolveTheme(spec.theme, spec);
|
|
293
322
|
spec = { ...spec, theme, colors: spec.colors || theme.colors, background: spec.background || theme.background, padding: spec.padding || theme.layout.padding };
|
|
294
|
-
const diagram = ['flow', 'swimlane'].includes(spec.type);
|
|
323
|
+
const diagram = ['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type);
|
|
295
324
|
const rawRows = diagram ? spec.nodes ?? spec.data.nodes ?? [] : spec.data.values;
|
|
296
325
|
const sourceRows = rawRows.map(row => ({ ...row }));
|
|
297
326
|
const linked = createLinkedProjectState(sourceRows, projectConfig(spec).linked || {});
|
|
@@ -299,9 +328,13 @@ export function buildProjectScene(spec) {
|
|
|
299
328
|
const data = { ...normalizeData(visibleRows), rows: visibleRows.map(row => ({ ...row })), sourceRows: sourceRows.map(row => ({ ...row })) };
|
|
300
329
|
const scene = new Scene(spec.width, spec.height);
|
|
301
330
|
scene.theme = spec.theme;
|
|
302
|
-
const left = ['gantt', 'timeline', 'milestone', 'swimlane'].includes(spec.type) ? Math.min(
|
|
303
|
-
const
|
|
304
|
-
const
|
|
331
|
+
const left = ['gantt', 'timeline', 'milestone', 'swimlane', 'architecture'].includes(spec.type) ? Math.min(170, spec.width * 0.34) : spec.padding.left;
|
|
332
|
+
const title = titleLayout(spec);
|
|
333
|
+
const plotTop = Math.max(spec.padding.top, title.bottom + (title.bottom ? 12 : 0)) + (spec.type === 'burndown' ? 16 : 0);
|
|
334
|
+
const plotWidth = Math.max(1, spec.width - left - spec.padding.right), xLabels = axisLabelLayout(spec, Array(Math.max(2, Math.min(5, Math.floor(plotWidth / 100)))).fill('2000-00-00'), plotWidth);
|
|
335
|
+
const bottomReserve = Math.max(spec.padding.bottom + 16, Math.ceil(16 + xLabels.projectedHeight));
|
|
336
|
+
const state = { plot: { x: left, y: plotTop, width: plotWidth, height: Math.max(1, spec.height - plotTop - bottomReserve) }, xLabels, compact: spec.width < 360 || spec.height < 240, recommendedSize: { minWidth: 280, minHeight: 220 }, linked, projectAnalytics: { linked } };
|
|
337
|
+
scene.xLabelLayout = xLabels;
|
|
305
338
|
if (spec.type === 'gantt') {
|
|
306
339
|
state.schedule = analyzeSchedule(data.rows, { calendar: projectConfig(spec).calendar || {} });
|
|
307
340
|
state.projectAnalytics.schedule = state.schedule;
|
|
@@ -321,8 +354,8 @@ export function buildProjectScene(spec) {
|
|
|
321
354
|
if (geometry.points) geometry.points = geometry.points.map(point => ({ x: mapX(point.x), y: mapY(point.y) }));
|
|
322
355
|
if (node.bounds) node.bounds = { x: mapX(node.bounds.x), y: mapY(node.bounds.y), width: node.bounds.width * view.scale, height: node.bounds.height * view.scale };
|
|
323
356
|
});
|
|
324
|
-
if (spec.title?.text) text(scene, 'title', spec.title.text, spec.width / 2,
|
|
325
|
-
if (spec.title?.subtitle) text(scene, 'subtitle', spec.title.subtitle, spec.width / 2,
|
|
357
|
+
if (spec.title?.text) text(scene, 'title', spec.title.text, spec.width / 2, title.titleY, { fill: spec.theme.text, font: spec.theme.typography.title.font, textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' });
|
|
358
|
+
if (spec.title?.subtitle) text(scene, 'subtitle', spec.title.subtitle, spec.width / 2, title.subtitleY, { fill: spec.theme.muted, font: spec.theme.typography.subtitle.font, textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' });
|
|
326
359
|
state.view = view;
|
|
327
360
|
return { scene, data, state };
|
|
328
361
|
}
|
|
@@ -339,6 +372,8 @@ export function projectTooltip(type, row) {
|
|
|
339
372
|
].filter(Boolean).join('\n');
|
|
340
373
|
const dependencies = (row.dependencies || []).map(dependencyId).filter(Boolean);
|
|
341
374
|
const variance = row.endVarianceDays ?? row.baselineVarianceDays ?? null;
|
|
375
|
+
if (type === 'mindmap') return [row.label || row.id, row.parentId ? `Parent: ${row.parentId}` : 'Root topic', row.branch ? `Branch: ${row.branch}` : null, row.description].filter(value => value != null && value !== '').join('\n');
|
|
376
|
+
if (type === 'architecture') return [row.label || row.id, row.layerId ? `Layer: ${row.layerId}` : null, row.boundaryId ? `Boundary: ${row.boundaryId}` : null, row.role ? `Role: ${row.role}` : null, row.description].filter(value => value != null && value !== '').join('\n');
|
|
342
377
|
return [
|
|
343
378
|
row.name || row.title || row.label || row.id,
|
|
344
379
|
row.start ? `${row.start} → ${row.end || row.start}` : row.date,
|
|
@@ -354,16 +389,21 @@ export function projectTooltip(type, row) {
|
|
|
354
389
|
}
|
|
355
390
|
|
|
356
391
|
export function rerouteDiagramScene(scene) {
|
|
392
|
+
const nodeGeometries = new Map();
|
|
393
|
+
scene.walk(item => { if (item.id.startsWith('node-') && item.dataRef?.nodeId && item.geometry) nodeGeometries.set(item.dataRef.nodeId, item.geometry); });
|
|
357
394
|
scene.walk(node => {
|
|
358
395
|
if (!node.id.startsWith('edge-') || !node.dataRef?.from || !node.dataRef?.to) return;
|
|
359
|
-
const source =
|
|
396
|
+
const source = nodeGeometries.get(node.dataRef.from), destination = nodeGeometries.get(node.dataRef.to);
|
|
360
397
|
if (!source || !destination) return;
|
|
361
|
-
const
|
|
362
|
-
const
|
|
363
|
-
node.geometry.points =
|
|
398
|
+
const obstacles = [...nodeGeometries.entries()].filter(([id]) => id !== node.dataRef.from && id !== node.dataRef.to).map(([, geometry]) => geometry);
|
|
399
|
+
const path = routeEdgePath({ ...node.dataRef, obstacles, grid: 8 }, source, destination, node.dataRef.routing || 'orthogonal');
|
|
400
|
+
node.geometry.points = path.points;
|
|
401
|
+
if (path.curve) node.geometry.curve = path.curve;
|
|
402
|
+
else delete node.geometry.curve;
|
|
403
|
+
const end = node.geometry.points.at(-1);
|
|
364
404
|
const arrowNode = scene.find(`${node.id}-arrow`), beforeTip = node.geometry.points.at(-2);
|
|
365
405
|
if (arrowNode) arrowNode.geometry.points = [end, { x: end.x - 7 * Math.cos(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) - Math.PI / 6), y: end.y - 7 * Math.sin(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) - Math.PI / 6) }, { x: end.x - 7 * Math.cos(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) + Math.PI / 6), y: end.y - 7 * Math.sin(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) + Math.PI / 6) }, end];
|
|
366
|
-
const edgeIndex = Number(node.id.slice(5)), label = scene.find(`edge-label-${edgeIndex}`), labelPoint = node.geometry.points[Math.floor(node.geometry.points.length / 2)];
|
|
406
|
+
const edgeIndex = Number(node.id.slice(5)), label = scene.find(`edge-label-${edgeIndex}`), labelPoint = node.geometry.curve === 'cubic' ? cubicBezierPoint(node.geometry.points, 0.5) : node.geometry.points[Math.floor(node.geometry.points.length / 2)];
|
|
367
407
|
if (label && labelPoint) { label.geometry.x = labelPoint.x; label.geometry.y = labelPoint.y - 8; }
|
|
368
408
|
});
|
|
369
409
|
return scene;
|
package/src/renderer.mjs
CHANGED
|
@@ -31,7 +31,7 @@ export class CanvasRenderer extends BaseRenderer {
|
|
|
31
31
|
mount(container) { super.mount(container); this.canvas = this.container.tagName === 'CANVAS' ? this.container : document.createElement('canvas'); if (this.canvas !== this.container) { this.container.innerHTML = ''; this.container.appendChild(this.canvas); } this.canvas.style.background = this.options.background || '#ffffff'; this.ctx = this.canvas.getContext('2d'); return this; }
|
|
32
32
|
resize(width, height, pixelRatio = globalThis.devicePixelRatio || 1) { this.width = width; this.height = height; this.pixelRatio = pixelRatio; this.canvas.width = Math.round(width * pixelRatio); this.canvas.height = Math.round(height * pixelRatio); if (this.canvas.style) { this.canvas.style.width = `${width}px`; this.canvas.style.height = `${height}px`; } this.ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); }
|
|
33
33
|
clear() { if (this.ctx) this.ctx.clearRect(0, 0, this.width, this.height); }
|
|
34
|
-
|
|
34
|
+
render(scene) { this.clear(); const ctx = this.ctx; if (hasFill({ fill: this.options?.background })) { ctx.save(); ctx.fillStyle = this.options.background; ctx.fillRect(0, 0, this.width, this.height); ctx.restore(); } const draw = node => { if (!node.visible) return; const g = node.geometry || {}; const s = node.style || {}; ctx.save(); ctx.globalAlpha = s.opacity == null ? 1 : s.opacity; ctx.fillStyle = hasFill(s) ? color(s, 'fill', node.highlighted || node.selected ? '#1d4ed8' : '#2563eb') : 'rgba(0,0,0,0)'; ctx.strokeStyle = hasStroke(s) ? color(s, 'stroke', '#0f172a') : 'rgba(0,0,0,0)'; ctx.lineWidth = s.strokeWidth || 1; if (node.type === 'rect') { if (hasFill(s)) ctx.fillRect(g.x, g.y, g.width, g.height); if (hasStroke(s)) ctx.strokeRect(g.x, g.y, g.width, g.height); } else if (node.type === 'line') { ctx.beginPath(); ctx.moveTo(g.x1, g.y1); ctx.lineTo(g.x2, g.y2); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'path') { ctx.beginPath(); if (g.curve === 'cubic' && g.points?.length === 4) { ctx.moveTo(g.points[0].x, g.points[0].y); ctx.bezierCurveTo(g.points[1].x, g.points[1].y, g.points[2].x, g.points[2].y, g.points[3].x, g.points[3].y); } else g.points.forEach((point, index) => index ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y)); if (g.closed) ctx.closePath(); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'circle') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, 0, Math.PI * 2); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'arc') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, g.start, g.end); if (g.innerR > 0) { ctx.arc(g.cx, g.cy, g.innerR, g.end, g.start, true); } else ctx.lineTo(g.cx, g.cy); ctx.closePath(); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'text') { ctx.fillStyle = hasFill(s) ? s.fill : '#0f172a'; ctx.font = s.font || '12px system-ui'; const align = s.textAlign || (s.textAnchor === 'end' ? 'end' : s.textAnchor === 'middle' || s.textAnchor === 'center' ? 'center' : s.textAnchor === 'start' ? 'start' : 'left'); ctx.textAlign = align; ctx.textBaseline = s.textBaseline || s.baseline || 'alphabetic'; if (Number.isFinite(Number(s.rotation)) && Number(s.rotation) !== 0) { ctx.translate(g.x, g.y); ctx.rotate(Number(s.rotation) * Math.PI / 180); ctx.fillText(g.text, 0, 0); } else ctx.fillText(g.text, g.x, g.y); } node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(draw); ctx.restore(); }; draw(scene.root); }
|
|
35
35
|
exportImage(type = 'image/png') { return this.canvas.toDataURL(type); }
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -86,6 +86,6 @@ export class SVGRenderer extends BaseRenderer {
|
|
|
86
86
|
this.svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
|
87
87
|
this.width = w; this.height = h;
|
|
88
88
|
}
|
|
89
|
-
this.clear(); const renderNode = node => { if (!node.visible || node.type === 'root') { node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); return; } const g = node.geometry || {}, s = node.style || {}; const element = document.createElementNS('http://www.w3.org/2000/svg', node.type === 'path' || node.type === 'arc' ? 'path' : node.type === 'text' ? 'text' : node.type); element.setAttribute('id', node.id); element.setAttribute('data-node-id', node.id); if (node.dataRef) element.dataset.dataRef = JSON.stringify(node.dataRef); if (node.type === 'rect') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); element.setAttribute('width', g.width); element.setAttribute('height', g.height); } else if (node.type === 'line') { ['x1', 'y1', 'x2', 'y2'].forEach(key => element.setAttribute(key, g[key])); } else if (node.type === 'circle') { element.setAttribute('cx', g.cx); element.setAttribute('cy', g.cy); element.setAttribute('r', g.r); } else if (node.type === 'arc') { const large = g.end - g.start > Math.PI ? 1 : 0, outerStart = `${g.cx + g.r * Math.cos(g.start)} ${g.cy + g.r * Math.sin(g.start)}`, outerEnd = `${g.cx + g.r * Math.cos(g.end)} ${g.cy + g.r * Math.sin(g.end)}`; const p = g.innerR > 0 ? [`M ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, `L ${g.cx + g.innerR * Math.cos(g.end)} ${g.cy + g.innerR * Math.sin(g.end)}`, `A ${g.innerR} ${g.innerR} 0 ${large} 0 ${g.cx + g.innerR * Math.cos(g.start)} ${g.cy + g.innerR * Math.sin(g.start)}`, 'Z'] : [`M ${g.cx} ${g.cy}`, `L ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, 'Z']; element.setAttribute('d', p.join(' ')); } else if (node.type === 'path')
|
|
89
|
+
this.clear(); const renderNode = node => { if (!node.visible || node.type === 'root') { node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); return; } const g = node.geometry || {}, s = node.style || {}; const element = document.createElementNS('http://www.w3.org/2000/svg', node.type === 'path' || node.type === 'arc' ? 'path' : node.type === 'text' ? 'text' : node.type); element.setAttribute('id', node.id); element.setAttribute('data-node-id', node.id); if (node.dataRef) element.dataset.dataRef = JSON.stringify(node.dataRef); if (node.type === 'rect') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); element.setAttribute('width', g.width); element.setAttribute('height', g.height); } else if (node.type === 'line') { ['x1', 'y1', 'x2', 'y2'].forEach(key => element.setAttribute(key, g[key])); } else if (node.type === 'circle') { element.setAttribute('cx', g.cx); element.setAttribute('cy', g.cy); element.setAttribute('r', g.r); } else if (node.type === 'arc') { const large = g.end - g.start > Math.PI ? 1 : 0, outerStart = `${g.cx + g.r * Math.cos(g.start)} ${g.cy + g.r * Math.sin(g.start)}`, outerEnd = `${g.cx + g.r * Math.cos(g.end)} ${g.cy + g.r * Math.sin(g.end)}`; const p = g.innerR > 0 ? [`M ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, `L ${g.cx + g.innerR * Math.cos(g.end)} ${g.cy + g.innerR * Math.sin(g.end)}`, `A ${g.innerR} ${g.innerR} 0 ${large} 0 ${g.cx + g.innerR * Math.cos(g.start)} ${g.cy + g.innerR * Math.sin(g.start)}`, 'Z'] : [`M ${g.cx} ${g.cy}`, `L ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, 'Z']; element.setAttribute('d', p.join(' ')); } else if (node.type === 'path') { const d = g.curve === 'cubic' && g.points?.length === 4 ? `M ${g.points[0].x} ${g.points[0].y} C ${g.points[1].x} ${g.points[1].y} ${g.points[2].x} ${g.points[2].y} ${g.points[3].x} ${g.points[3].y}` : `${g.points.map((p, i) => `${i ? 'L' : 'M'} ${p.x} ${p.y}`).join(' ')}${g.closed ? ' Z' : ''}`; element.setAttribute('d', d); } else if (node.type === 'text') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); if (Number.isFinite(Number(s.rotation)) && Number(s.rotation) !== 0) element.setAttribute('transform', `rotate(${Number(s.rotation)} ${g.x} ${g.y})`); element.textContent = g.text; const anchor = s.textAnchor || (s.textAlign === 'end' ? 'end' : s.textAlign === 'center' ? 'middle' : s.textAlign === 'right' ? 'end' : s.textAlign === 'left' ? 'start' : 'start'); if (anchor) element.setAttribute('text-anchor', anchor); const baseline = s.textBaseline || s.baseline || 'alphabetic'; if (baseline === 'top') { element.setAttribute('dominant-baseline', 'text-before-edge'); element.setAttribute('alignment-baseline', 'before-edge'); } else if (baseline === 'middle' || baseline === 'central') { element.setAttribute('dominant-baseline', 'middle'); element.setAttribute('alignment-baseline', 'middle'); } else if (baseline === 'bottom' || baseline === 'hanging') { element.setAttribute('dominant-baseline', 'text-after-edge'); element.setAttribute('alignment-baseline', 'after-edge'); } else { element.setAttribute('dominant-baseline', 'alphabetic'); element.setAttribute('alignment-baseline', 'alphabetic'); } if (s.font) { const raw = String(s.font).trim(); element.setAttribute('font', raw); const sizeMatch = raw.match(/\b(\d+(?:\.\d+)?)\s*(px|em|rem|pt|%)/i); const size = sizeMatch ? `${sizeMatch[1]}${sizeMatch[2].toLowerCase()}` : ''; if (size) element.style.fontSize = size; const weightMatch = raw.match(/^\s*(\d{3}|normal|bold|lighter|bolder)\b/i); if (weightMatch) element.style.fontWeight = weightMatch[1]; const family = raw.replace(/^\s*(?:(?:normal|italic|oblique)(?:\s+[^0-9\s]+)?\s+)?(?:\d{3}|normal|bold|lighter|bolder)\s+/i, '').replace(/\b\d+(?:\.\d+)?\s*(?:px|em|rem|pt|%)(?:\s*\/\s*\S+)?\s*/i, '').trim(); if (family) element.style.fontFamily = family; } } if (node.type === 'path' || node.type === 'circle' || node.type === 'rect' || node.type === 'arc') { element.setAttribute('fill', hasFill(s) ? s.fill : 'none'); } else if (node.type === 'text') { element.setAttribute('fill', hasFill(s) ? s.fill : '#0f172a'); } if (hasStroke(s)) element.setAttribute('stroke', s.stroke); else if (s.stroke != null && typeof s.stroke === 'string' && NO_FILL_TOKENS.has(s.stroke.trim().toLowerCase())) element.setAttribute('stroke', 'none'); if (s.strokeWidth != null) element.setAttribute('stroke-width', s.strokeWidth); if (s.opacity != null) element.setAttribute('opacity', node.highlighted || node.selected ? 1 : s.opacity); if (node.highlighted || node.selected) element.setAttribute('filter', 'brightness(1.2)'); if (node.interactive) element.style.cursor = 'pointer'; if (s.pointerEvents === 'none') element.style.pointerEvents = 'none'; if (s.ariaHidden === 'true') element.setAttribute('aria-hidden', 'true'); if (s.role === 'presentation') element.setAttribute('role', 'presentation'); if (node.decorative) { element.setAttribute('aria-hidden', 'true'); element.setAttribute('role', 'presentation'); element.style.pointerEvents = 'none'; element.style.userSelect = 'none'; } this.svg.appendChild(element); node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }; scene.root.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }
|
|
90
90
|
exportString() { if (!this.svg) return ''; return new XMLSerializer().serializeToString(this.svg); }
|
|
91
91
|
}
|
package/src/scene.mjs
CHANGED
|
@@ -9,10 +9,41 @@ export class SceneNode {
|
|
|
9
9
|
add(node) { this.children.push(node instanceof SceneNode ? node : new SceneNode(node)); return node; }
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export function pointToSegmentDistance(point, start, end) {
|
|
13
|
+
const dx = end.x - start.x, dy = end.y - start.y;
|
|
14
|
+
if (!dx && !dy) return Math.hypot(point.x - start.x, point.y - start.y);
|
|
15
|
+
const ratio = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy)));
|
|
16
|
+
return Math.hypot(point.x - (start.x + ratio * dx), point.y - (start.y + ratio * dy));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function cubicBezierPoint(points, t) {
|
|
20
|
+
if (!Array.isArray(points) || points.length !== 4) return points?.[0] || { x: 0, y: 0 };
|
|
21
|
+
const ratio = Math.max(0, Math.min(1, Number(t) || 0)), inverse = 1 - ratio;
|
|
22
|
+
return {
|
|
23
|
+
x: inverse ** 3 * points[0].x + 3 * inverse ** 2 * ratio * points[1].x + 3 * inverse * ratio ** 2 * points[2].x + ratio ** 3 * points[3].x,
|
|
24
|
+
y: inverse ** 3 * points[0].y + 3 * inverse ** 2 * ratio * points[1].y + 3 * inverse * ratio ** 2 * points[2].y + ratio ** 3 * points[3].y
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sampleCubicBezier(points, segments = 20) {
|
|
29
|
+
const count = Math.max(4, Math.round(Number(segments) || 20));
|
|
30
|
+
return Array.from({ length: count + 1 }, (_, index) => cubicBezierPoint(points, index / count));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function geometryHit(node, x, y) {
|
|
34
|
+
const geometry = node.geometry || {}, tolerance = Number(node.hitTolerance) || Math.max(6, Number(node.style?.strokeWidth) + 4 || 6);
|
|
35
|
+
if (node.type === 'line') return pointToSegmentDistance({ x, y }, { x: geometry.x1, y: geometry.y1 }, { x: geometry.x2, y: geometry.y2 }) <= tolerance;
|
|
36
|
+
if (node.type === 'path' && Array.isArray(geometry.points)) {
|
|
37
|
+
const points = geometry.curve === 'cubic' ? sampleCubicBezier(geometry.points) : geometry.points;
|
|
38
|
+
return points.some((point, index) => index > 0 && pointToSegmentDistance({ x, y }, points[index - 1], point) <= tolerance);
|
|
39
|
+
}
|
|
40
|
+
return Boolean(node.bounds && x >= node.bounds.x && x <= node.bounds.x + node.bounds.width && y >= node.bounds.y && y <= node.bounds.y + node.bounds.height);
|
|
41
|
+
}
|
|
42
|
+
|
|
12
43
|
export class Scene {
|
|
13
44
|
constructor(width, height) { this.width = width; this.height = height; this.root = new SceneNode({ id: 'root', type: 'group' }); }
|
|
14
45
|
add(node) { return this.root.add(node); }
|
|
15
46
|
walk(fn, node = this.root) { fn(node); node.children.forEach(child => this.walk(fn, child)); }
|
|
16
47
|
find(id) { let found; this.walk(node => { if (node.id === id) found = node; }); return found; }
|
|
17
|
-
hit(x, y) { const hits = []; this.walk(node => { if (node.visible && node.interactive && node
|
|
48
|
+
hit(x, y) { const hits = []; this.walk(node => { if (node.visible && node.interactive && geometryHit(node, x, y)) hits.push(node); }); return hits.sort((a, b) => b.zIndex - a.zIndex)[0] || null; }
|
|
18
49
|
}
|
package/src/schema.mjs
CHANGED
|
@@ -80,9 +80,14 @@ const models = {
|
|
|
80
80
|
toPort: { type: 'string', editable: true },
|
|
81
81
|
label: { type: 'string', editable: true },
|
|
82
82
|
status,
|
|
83
|
-
routing: { type: 'enum', values: ['straight', 'orthogonal', 'curved'], editable: true }
|
|
83
|
+
routing: { type: 'enum', values: ['straight', 'orthogonal', 'curved'], editable: true },
|
|
84
|
+
curveTension: { type: 'number', min: 0.2, max: 0.8, editable: true },
|
|
85
|
+
waypoints: { type: 'array', editable: true, items: { type: 'object', properties: { x: { type: 'number', required: true }, y: { type: 'number', required: true } } } }
|
|
84
86
|
}),
|
|
85
|
-
swimlane: schema('swimlane', { label: { type: 'string', required: true, editable: true } })
|
|
87
|
+
swimlane: schema('swimlane', { label: { type: 'string', required: true, editable: true } }),
|
|
88
|
+
'architecture-node': schema('architecture-node', { label: { type: 'string', required: true, editable: true }, layerId: { type: 'string', editable: true }, boundaryId: { type: 'string', editable: true }, position, size: { type: 'object', editable: true, properties: { width: { type: 'number', required: true, min: 1 }, height: { type: 'number', required: true, min: 1 } } }, ports: { type: 'array', editable: false, items: { type: 'object', properties: { id: { type: 'string', required: true }, side: { type: 'enum', values: ['left', 'right', 'top', 'bottom'] }, offset: { type: 'number', min: 0, max: 1 } } } }, description: { type: 'string', editable: true }, status }),
|
|
89
|
+
'architecture-edge': schema('architecture-edge', { from: { type: 'string', required: true, editable: true }, to: { type: 'string', required: true, editable: true }, label: { type: 'string', editable: true }, relation: { type: 'string', editable: true }, routing: { type: 'enum', values: ['straight', 'orthogonal', 'curved'], editable: true }, curveTension: { type: 'number', min: 0.2, max: 0.8, editable: true }, waypoints: { type: 'array', editable: true, items: { type: 'object', properties: { x: { type: 'number', required: true }, y: { type: 'number', required: true } } } }, status }),
|
|
90
|
+
'mindmap-node': schema('mindmap-node', { label: { type: 'string', required: true, editable: true }, parentId: { type: 'string', editable: true }, position, size: { type: 'object', editable: true, properties: { width: { type: 'number', required: true, min: 1 }, height: { type: 'number', required: true, min: 1 } } }, branch: { type: 'string', editable: true }, description: { type: 'string', editable: true }, status })
|
|
86
91
|
};
|
|
87
92
|
|
|
88
93
|
export function getBusinessSchema(name) {
|
package/src/spec.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { chartProfiles } from './capabilities.mjs';
|
|
6
6
|
import { themeModes, themePalettes, themePresets } from './theme.mjs';
|
|
7
|
+
import { validateDiagram } from './diagram.mjs';
|
|
7
8
|
|
|
8
9
|
const chartTypes = new Set(Object.keys(chartProfiles));
|
|
9
10
|
|
|
@@ -21,8 +22,9 @@ const defaults = {
|
|
|
21
22
|
grid: { visible: true },
|
|
22
23
|
labels: { enabled: false },
|
|
23
24
|
branding: { enabled: true },
|
|
24
|
-
interaction: { tooltip: true, hover: true, click: true, crosshair: false, zoom: false, brush: false, keyboard: true }
|
|
25
|
-
,responsive: [], accessibility: { enabled: false }, editing: { enabled: false, mode: 'command', requireConfirmation: true, allowDelete: false, allowStructuralChanges: false }, xAxis: {}, yAxis: {}
|
|
25
|
+
interaction: { tooltip: true, hover: true, click: true, crosshair: false, zoom: false, pan: false, brush: false, drag: false, edgeDrag: false, portConnect: false, keyboard: true }
|
|
26
|
+
,responsive: [], accessibility: { enabled: false }, editing: { enabled: false, mode: 'command', requireConfirmation: true, allowDelete: false, allowStructuralChanges: false }, xAxis: {}, yAxis: { nice: true, ticks: 'auto' }
|
|
27
|
+
,locale: 'en-US'
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
function clone(value) {
|
|
@@ -58,6 +60,41 @@ function dependencyErrors(rows) {
|
|
|
58
60
|
return errors;
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
const encodingChannels = {
|
|
64
|
+
line: ['x', 'y'], area: ['x', 'y'], bar: ['x', 'y'], column: ['x', 'y'], scatter: ['x', 'y'],
|
|
65
|
+
pie: ['category', 'value'], funnel: ['category', 'value'], gauge: ['category', 'value'], heatmap: ['x', 'y', 'color'], radar: [],
|
|
66
|
+
gantt: [], timeline: [], milestone: [], burndown: [], flow: [], swimlane: [], architecture: [], mindmap: []
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const presentationEncodingKeys = new Set(['title', 'format', 'labels', 'legend']);
|
|
70
|
+
|
|
71
|
+
function finiteDomain(domain) {
|
|
72
|
+
return Array.isArray(domain) && domain.length === 2 && domain.every(value => Number.isFinite(Number(value))) && Number(domain[1]) > Number(domain[0]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validateEncodingContract(input, spec, errors) {
|
|
76
|
+
const rows = Array.isArray(spec.data?.values) ? spec.data.values : [], fields = new Set(rows.flatMap(row => Object.keys(row || {})));
|
|
77
|
+
if (!rows.length) return;
|
|
78
|
+
const rawEncoding = input.encoding && typeof input.encoding === 'object' ? input.encoding : {};
|
|
79
|
+
const allowed = new Set(encodingChannels[spec.type] || []);
|
|
80
|
+
Object.keys(rawEncoding).forEach(channel => {
|
|
81
|
+
if (presentationEncodingKeys.has(channel) || allowed.has(channel)) return;
|
|
82
|
+
errors.push({ code: 'UNSUPPORTED_ENCODING_CHANNEL', path: `encoding.${channel}`, message: `${spec.type} does not use encoding.${channel}.`, expected: [...allowed], suggestion: allowed.size ? `Use encoding.${[...allowed].join(' or encoding.')} for ${spec.type}.` : 'Remove field encodings from this chart type.' });
|
|
83
|
+
});
|
|
84
|
+
const check = (path, encoding) => {
|
|
85
|
+
const items = Array.isArray(encoding) ? encoding : [encoding];
|
|
86
|
+
items.forEach((item, index) => {
|
|
87
|
+
if (!item) return;
|
|
88
|
+
const field = item.field, itemPath = Array.isArray(encoding) ? `${path}.${index}.field` : `${path}.field`;
|
|
89
|
+
if (typeof field !== 'string' || !field) errors.push({ code: 'INVALID_ENCODING_FIELD', path: itemPath, message: 'Encoding fields must be non-empty strings.', suggestion: 'Use a field name that exists in data.values.' });
|
|
90
|
+
else if (!fields.has(field)) errors.push({ code: 'MISSING_ENCODING_FIELD', path: itemPath, message: `Field ${field} is not present in data.values.`, expected: [...fields], suggestion: 'Inspect the data schema and use an existing field.' });
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
if (['pie', 'funnel', 'gauge'].includes(spec.type) && (rawEncoding.x !== undefined || rawEncoding.y !== undefined)) return;
|
|
94
|
+
(encodingChannels[spec.type] || []).forEach(channel => check(`encoding.${channel}`, spec.encoding?.[channel]));
|
|
95
|
+
if (spec.type === 'radar') (spec.indicators || []).forEach((indicator, index) => check(`indicators.${index}`, indicator));
|
|
96
|
+
}
|
|
97
|
+
|
|
61
98
|
export function normalizeSpec(input = {}) {
|
|
62
99
|
const spec = merge(defaults, input);
|
|
63
100
|
if (input.branding === undefined && input.theme && typeof input.theme === 'object' && input.theme.branding !== undefined) spec.branding = clone(input.theme.branding);
|
|
@@ -68,7 +105,7 @@ export function normalizeSpec(input = {}) {
|
|
|
68
105
|
if (spec.branding.enabled === undefined) spec.branding = { ...spec.branding, enabled: true };
|
|
69
106
|
}
|
|
70
107
|
if (!spec.data) spec.data = { values: [] };
|
|
71
|
-
if (['flow', 'swimlane'].includes(spec.type) && spec.nodes && !spec.data.values) spec.data.values = [];
|
|
108
|
+
if (['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type) && spec.nodes && !spec.data.values) spec.data.values = [];
|
|
72
109
|
if (Array.isArray(spec.data)) spec.data = { values: spec.data };
|
|
73
110
|
if (!spec.encoding) spec.encoding = {};
|
|
74
111
|
if (['pie', 'funnel', 'gauge'].includes(spec.type) && !spec.encoding.category) spec.encoding.category = { field: 'name', type: 'category' };
|
|
@@ -76,14 +113,14 @@ export function normalizeSpec(input = {}) {
|
|
|
76
113
|
if (spec.type === 'scatter' && !input.encoding?.x) spec.encoding.x = { field: 'x', type: 'quantitative' };
|
|
77
114
|
if (spec.type === 'scatter' && !input.encoding?.y) spec.encoding.y = { field: 'y', type: 'quantitative' };
|
|
78
115
|
if (spec.type === 'heatmap') { spec.encoding.x ||= { field: 'x', type: 'category' }; spec.encoding.y ||= { field: 'y', type: 'category' }; spec.encoding.color ||= { field: 'value', type: 'quantitative' }; }
|
|
79
|
-
if (!['pie', 'funnel', 'gauge', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane'].includes(spec.type)) {
|
|
116
|
+
if (!['pie', 'funnel', 'gauge', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type)) {
|
|
80
117
|
spec.encoding.x = spec.encoding.x || { field: 'name', type: 'category' };
|
|
81
118
|
spec.encoding.y = spec.encoding.y || { field: 'value', type: 'quantitative' };
|
|
82
119
|
}
|
|
83
120
|
return spec;
|
|
84
121
|
}
|
|
85
122
|
|
|
86
|
-
export function validateSpec(input) {
|
|
123
|
+
export function validateSpec(input = {}) {
|
|
87
124
|
const spec = normalizeSpec(input);
|
|
88
125
|
const errors = [], warnings = [], normalizations = [];
|
|
89
126
|
if (!chartTypes.has(spec.type)) errors.push({ code: 'INVALID_TYPE', path: 'type', message: `Unsupported chart type: ${spec.type}`, suggestion: 'Use a type returned by getCapabilities().' });
|
|
@@ -96,13 +133,52 @@ export function validateSpec(input) {
|
|
|
96
133
|
if (spec.theme.branding != null && typeof spec.theme.branding !== 'boolean' && !(spec.theme.branding && typeof spec.theme.branding === 'object')) errors.push({ code: 'INVALID_BRANDING', path: 'theme.branding', message: 'theme.branding must be a boolean or a { enabled: boolean } object.', suggestion: 'Use theme: { branding: false } or theme: { branding: { enabled: false } }.' });
|
|
97
134
|
else if (spec.theme.branding && typeof spec.theme.branding === 'object' && (Object.keys(spec.theme.branding).some(key => key !== 'enabled') || typeof spec.theme.branding.enabled !== 'boolean')) errors.push({ code: 'INVALID_BRANDING', path: 'theme.branding', message: 'theme.branding objects only support a boolean enabled property.', suggestion: 'Use theme: { branding: { enabled: false } }.' });
|
|
98
135
|
}
|
|
136
|
+
if (typeof spec.locale !== 'string' || !spec.locale.trim()) errors.push({ code: 'INVALID_LOCALE', path: 'locale', message: 'locale must be a non-empty BCP 47 locale string.', suggestion: 'Use for example locale: "en-US" or locale: "zh-CN".' });
|
|
137
|
+
validateEncodingContract(input, spec, errors);
|
|
138
|
+
const axisEncodings = [['x', 'xAxis'], ['y', 'yAxis']];
|
|
139
|
+
axisEncodings.forEach(([encodingName, axisName]) => {
|
|
140
|
+
const encoding = spec.encoding?.[encodingName];
|
|
141
|
+
const encodingItems = Array.isArray(encoding) ? encoding : [encoding];
|
|
142
|
+
encodingItems.forEach((item, index) => {
|
|
143
|
+
if (!item || typeof item !== 'object') return;
|
|
144
|
+
const pathPrefix = Array.isArray(encoding) ? `encoding.${encodingName}.${index}` : `encoding.${encodingName}`;
|
|
145
|
+
if (item.title !== undefined) warnings.push({ code: 'MISPLACED_AXIS_TITLE', path: `${pathPrefix}.title`, message: `Axis titles belong on ${axisName}.title, not on an encoding.`, suggestion: `Move it to ${axisName}: { title: ... }.` });
|
|
146
|
+
if (item.format !== undefined) warnings.push({ code: 'MISPLACED_AXIS_FORMAT', path: `${pathPrefix}.format`, message: `Axis formats belong on ${axisName}.format, not on an encoding.`, suggestion: `Move it to ${axisName}: { format: ... }.` });
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
if (spec.encoding?.labels !== undefined) warnings.push({ code: 'MISPLACED_LABELS', path: 'encoding.labels', message: 'Data labels are a chart-level option.', suggestion: 'Move it to labels: { enabled: true, ... }.' });
|
|
150
|
+
if (spec.encoding?.legend !== undefined) warnings.push({ code: 'MISPLACED_LEGEND', path: 'encoding.legend', message: 'Legend visibility is a chart-level option.', suggestion: 'Move it to legend: { visible: false }.' });
|
|
151
|
+
[['xAxis', spec.xAxis], ['yAxis', spec.yAxis]].forEach(([axisName, axis]) => {
|
|
152
|
+
['min', 'max'].forEach(bound => {
|
|
153
|
+
if (axis?.[bound] !== undefined || axis?.right?.[bound] !== undefined) warnings.push({ code: 'UNSUPPORTED_AXIS_DOMAIN', path: `${axisName}.${bound}`, message: 'Use axis.domain rather than axis.min/axis.max for an explicit numeric domain.', suggestion: 'Use yAxis: { domain: [0, 2000] } or yAxis: { nice: true }.' });
|
|
154
|
+
});
|
|
155
|
+
if (axisName === 'xAxis' && axis?.domain !== undefined) warnings.push({ code: 'UNSUPPORTED_AXIS_DOMAIN', path: 'xAxis.domain', message: 'Explicit x-axis domain is not supported by the current categorical/time layout.', suggestion: 'Use yAxis.domain for numeric value axes; x-axis ranges are derived from records.' });
|
|
156
|
+
if (axisName === 'xAxis' && (axis?.nice !== undefined || axis?.ticks !== undefined)) warnings.push({ code: 'UNSUPPORTED_AXIS_TICKS', path: 'xAxis', message: 'Nice domains and tick counts currently apply to yAxis, not categorical/time x-axis layouts.', suggestion: 'Move numeric scale controls to yAxis.' });
|
|
157
|
+
if (axisName === 'xAxis') return;
|
|
158
|
+
[axis, axis?.right].forEach((axisConfig, index) => {
|
|
159
|
+
if (!axisConfig) return;
|
|
160
|
+
const pathPrefix = index ? `${axisName}.right` : axisName;
|
|
161
|
+
if (axisConfig.domain !== undefined && !(Array.isArray(axisConfig.domain) && axisConfig.domain.length === 2 && axisConfig.domain.every(value => Number.isFinite(Number(value))) && Number(axisConfig.domain[1]) > Number(axisConfig.domain[0]))) errors.push({ code: 'INVALID_AXIS_DOMAIN', path: `${pathPrefix}.domain`, message: 'Axis domain must be [min, max] with two finite numbers and max greater than min.', suggestion: 'Use for example yAxis: { domain: [0, 2000] }.' });
|
|
162
|
+
if (axisConfig.nice !== undefined && typeof axisConfig.nice !== 'boolean') errors.push({ code: 'INVALID_AXIS_NICE', path: `${pathPrefix}.nice`, message: 'Axis nice must be a boolean.', suggestion: 'Use nice: true or nice: false.' });
|
|
163
|
+
if (axisConfig.ticks !== undefined && axisConfig.ticks !== 'auto' && !(Number.isInteger(axisConfig.ticks) && axisConfig.ticks >= 2)) errors.push({ code: 'INVALID_AXIS_TICKS', path: `${pathPrefix}.ticks`, message: 'Axis ticks must be auto or an integer of at least 2 labels.', suggestion: 'Use ticks: "auto" or ticks: 5.' });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
99
166
|
if (Array.isArray(spec.encoding.y) && spec.encoding.y.length > 2) errors.push({ code: 'TOO_MANY_AXES', path: 'encoding.y', message: 'Only two quantitative axes are supported in this iteration.', suggestion: 'Use at most two y encodings.' });
|
|
100
167
|
if (spec.stack && !['bar', 'column', 'area'].includes(spec.type)) errors.push({ code: 'INVALID_STACK', path: 'stack', message: 'Stacking is supported by bar, column, and area charts.', suggestion: 'Remove stack or use a supported chart type.' });
|
|
101
168
|
if (spec.stack && !['stacked', 'percent'].includes(typeof spec.stack === 'string' ? spec.stack : spec.stack.mode)) errors.push({ code: 'INVALID_STACK_MODE', path: 'stack', message: 'Stack mode must be stacked or percent.', suggestion: 'Use stack: "stacked" or stack: "percent".' });
|
|
102
169
|
if (spec.type === 'pie' && spec.innerRadius != null && (!(Number(spec.innerRadius) >= 0) || Number(spec.innerRadius) >= 1)) errors.push({ code: 'INVALID_INNER_RADIUS', path: 'innerRadius', message: 'Pie innerRadius must be a ratio from 0 up to, but not including, 1.', suggestion: 'Use a value such as 0.55.' });
|
|
170
|
+
if (spec.type === 'gauge') {
|
|
171
|
+
if (spec.domain === undefined) errors.push({ code: 'MISSING_GAUGE_DOMAIN', path: 'domain', message: 'Gauge requires an explicit numeric domain to avoid silently clipping values.', suggestion: 'Use domain: [0, 100] for a percentage KPI or declare the business range.' });
|
|
172
|
+
else if (!finiteDomain(spec.domain)) errors.push({ code: 'INVALID_GAUGE_DOMAIN', path: 'domain', message: 'Gauge domain must be [min, max] with finite numbers and max greater than min.', suggestion: 'Use for example domain: [0, 100].' });
|
|
173
|
+
}
|
|
103
174
|
if (spec.type === 'radar' && (!Array.isArray(spec.indicators) || spec.indicators.length < 3)) errors.push({ code: 'INVALID_INDICATORS', path: 'indicators', message: 'Radar requires at least three indicators.', suggestion: 'Declare indicator name, field, min, and max.' });
|
|
104
175
|
if (!spec.data || !Array.isArray(spec.data.values)) errors.push({ code: 'INVALID_DATA', path: 'data.values', message: 'data.values must be an array.', suggestion: 'Pass an array of row objects.' });
|
|
105
|
-
if (['flow', 'swimlane'].includes(spec.type) && !Array.isArray(spec.nodes) && !Array.isArray(spec.data?.nodes)) errors.push({ code: 'INVALID_NODES', path: 'nodes', message:
|
|
176
|
+
if (['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type) && !Array.isArray(spec.nodes) && !Array.isArray(spec.data?.nodes)) errors.push({ code: 'INVALID_NODES', path: 'nodes', message: `${spec.type} charts require nodes.`, suggestion: 'Pass nodes on the Spec or in data.nodes.' });
|
|
177
|
+
if (['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type)) {
|
|
178
|
+
const diagramInput = { type: spec.type, ...(Array.isArray(spec.nodes) ? { nodes: spec.nodes } : {}), ...(Array.isArray(spec.data?.nodes) ? { nodes: spec.data.nodes } : {}), ...(Array.isArray(spec.edges) ? { edges: spec.edges } : {}), ...(Array.isArray(spec.data?.edges) ? { edges: spec.data.edges } : {}), ...(Array.isArray(spec.lanes) ? { lanes: spec.lanes } : {}), ...(Array.isArray(spec.groups) ? { groups: spec.groups } : {}), ...(Array.isArray(spec.layers) ? { layers: spec.layers } : {}), ...(Array.isArray(spec.boundaries) ? { boundaries: spec.boundaries } : {}), ...(spec.diagram ? { diagram: spec.diagram } : {}) };
|
|
179
|
+
const diagramValidation = validateDiagram(diagramInput);
|
|
180
|
+
diagramValidation.errors.forEach(error => errors.push({ ...error, path: error.path || 'diagram' }));
|
|
181
|
+
}
|
|
106
182
|
if (['gantt', 'timeline', 'milestone'].includes(spec.type)) {
|
|
107
183
|
const rows = spec.data.values, dateKey = spec.type === 'gantt' ? 'start/end' : 'date';
|
|
108
184
|
if (!rows.some(row => row.start || row.end || row.date)) errors.push({ code: 'INVALID_INTERVALS', path: 'data.values', message: `${spec.type} rows require ${dateKey} dates.`, suggestion: 'Add ISO date values to each row.' });
|
|
@@ -111,6 +187,12 @@ export function validateSpec(input) {
|
|
|
111
187
|
if (spec.type === 'gantt') errors.push(...dependencyErrors(spec.data.values));
|
|
112
188
|
if (spec.type === 'pie' && spec.data.values.length > 8) warnings.push({ code: 'HIGH_CARDINALITY_PIE', path: 'data.values', message: `Pie contains ${spec.data.values.length} categories.`, expected: '8 or fewer categories', suggestion: 'Use bar/column or group smaller categories.' });
|
|
113
189
|
if (spec.type === 'radar' && Array.isArray(spec.indicators) && spec.indicators.some(indicator => !Number.isFinite(Number(indicator.min)) || !Number.isFinite(Number(indicator.max)))) warnings.push({ code: 'AMBIGUOUS_RADAR_DOMAIN', path: 'indicators', message: 'Radar indicator domains are incomplete.', expected: 'finite min and max for every indicator', suggestion: 'Declare explicit domains, especially for mixed units.' });
|
|
190
|
+
const profile = chartProfiles[spec.type], inputOptions = input && typeof input === 'object' ? input : {};
|
|
191
|
+
[['legend', 'legend'], ['grid', 'grid'], ['labels', 'labels']].forEach(([option, feature]) => {
|
|
192
|
+
if (inputOptions[option] !== undefined && profile?.features?.[feature] !== 'supported') warnings.push({ code: `UNSUPPORTED_${option.toUpperCase()}`, path: option, message: `${spec.type} does not support ${option} configuration in the current contract.`, suggestion: 'Remove the option or use a chart type that declares this feature.' });
|
|
193
|
+
});
|
|
194
|
+
if ((inputOptions.xAxis !== undefined || inputOptions.yAxis !== undefined) && !['line', 'area', 'bar', 'column', 'scatter'].includes(spec.type)) warnings.push({ code: 'UNSUPPORTED_AXIS', path: inputOptions.xAxis !== undefined ? 'xAxis' : 'yAxis', message: `${spec.type} does not expose Cartesian axis configuration.`, suggestion: 'Use chart-specific options such as domain, colorScale, or indicators.' });
|
|
195
|
+
if (spec.type === 'swimlane' && !((Array.isArray(spec.lanes) && spec.lanes.length) || (Array.isArray(spec.data?.lanes) && spec.data.lanes.length))) errors.push({ code: 'MISSING_REQUIRED', path: 'lanes', message: 'Swimlane requires at least one lane.', suggestion: 'Provide lanes: [{ id, label }].' });
|
|
114
196
|
const supportedInteractions = chartProfiles[spec.type]?.interactions || [];
|
|
115
197
|
Object.entries(spec.interaction || {}).forEach(([name, enabled]) => { if (enabled && !supportedInteractions.includes(name) && !['hover', 'click'].includes(name)) warnings.push({ code: 'UNSUPPORTED_INTERACTION', path: `interaction.${name}`, message: `${name} is not declared for ${spec.type}.`, expected: supportedInteractions, suggestion: 'Disable the interaction or use a compatible chart type.' }); });
|
|
116
198
|
if (spec.branding != null && typeof spec.branding !== 'boolean' && !(spec.branding && typeof spec.branding === 'object')) {
|
package/src/theme.mjs
CHANGED
|
@@ -66,7 +66,7 @@ const presets = {
|
|
|
66
66
|
};
|
|
67
67
|
|
|
68
68
|
const projectTypes = new Set(['gantt', 'timeline', 'milestone', 'burndown']);
|
|
69
|
-
const diagramTypes = new Set(['flow', 'swimlane']);
|
|
69
|
+
const diagramTypes = new Set(['flow', 'swimlane', 'architecture', 'mindmap']);
|
|
70
70
|
const structuredKeys = new Set(['preset', 'mode', 'palette', 'tokens']);
|
|
71
71
|
|
|
72
72
|
function clone(value) { return value == null ? value : JSON.parse(JSON.stringify(value)); }
|