@taylorwong/ichartjs 2.0.6 → 2.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +2 -2
- package/docs/agent/README.md +3 -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/runtime-contract.md +11 -1
- 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/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/runtime-contract.md +3 -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 +30 -3
- package/docs/manifests/commands.json +10 -9
- package/docs/manifests/schemas.json +5 -2
- package/package.json +1 -1
- package/skills/ichartjs/SKILL.md +2 -1
- package/src/capabilities.mjs +60 -12
- package/src/charts.mjs +127 -44
- 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 +34 -15
- 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 +10 -4
- package/src/theme.mjs +1 -1
- package/types/index.d.ts +22 -4
package/src/preferences-ui.mjs
CHANGED
|
@@ -2,20 +2,19 @@
|
|
|
2
2
|
* Optional, framework-neutral quick settings popover for browser hosts.
|
|
3
3
|
* It stays outside the SVG/Canvas surface and is never part of exports.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
6
|
-
import { contrastRatio
|
|
5
|
+
import { getPreferenceCapabilities } from './capabilities.mjs';
|
|
6
|
+
import { contrastRatio } from './theme.mjs';
|
|
7
7
|
|
|
8
8
|
const styleId = 'ichart-preferences-ui-style';
|
|
9
9
|
const messages = {
|
|
10
|
-
'zh-CN': {
|
|
11
|
-
|
|
12
|
-
},
|
|
13
|
-
en: {
|
|
14
|
-
title: 'Chart quick settings', mode: 'Theme', palette: 'Palette', scale: 'Font size', automatic: 'Auto', defaultSize: 'Default', small: 'Small', large: 'Large', extraLarge: 'Extra large', legend: 'Legend', labels: 'Data labels', grid: 'Grid lines'
|
|
15
|
-
}
|
|
10
|
+
'zh-CN': { title: '图表快捷设置' },
|
|
11
|
+
en: { title: 'Chart quick settings' }
|
|
16
12
|
};
|
|
13
|
+
const placements = ['right', 'top', 'bottom'];
|
|
14
|
+
let panelSequence = 0;
|
|
17
15
|
|
|
18
16
|
function option(label, value) { return `<option value="${value}">${label}</option>`; }
|
|
17
|
+
function optionMarkup(field) { return field.options.map(item => option(item.label, item.value == null ? 'auto' : item.value)).join(''); }
|
|
19
18
|
function safe(value) { return value == null ? '' : String(value); }
|
|
20
19
|
function localeKey(locale) {
|
|
21
20
|
const requested = locale && locale !== 'auto' ? locale : document.documentElement.lang || navigator.language || 'en';
|
|
@@ -25,30 +24,60 @@ function localeKey(locale) {
|
|
|
25
24
|
return 'en';
|
|
26
25
|
}
|
|
27
26
|
|
|
27
|
+
export function resolveChartSettingsPlacement(anchor, panel, viewport, preferredPlacements = placements, gap = 10, margin = 8) {
|
|
28
|
+
const boundary = { left: (viewport.left || 0) + margin, top: (viewport.top || 0) + margin, right: (viewport.left || 0) + viewport.width - margin, bottom: (viewport.top || 0) + viewport.height - margin };
|
|
29
|
+
const candidates = Object.fromEntries(placements.map(value => [value, chartSettingsPlacementCoordinates(anchor, panel, value, gap)]));
|
|
30
|
+
const order = [...new Set(preferredPlacements.filter(value => placements.includes(value)))];
|
|
31
|
+
if (!order.length) order.push(...placements);
|
|
32
|
+
const fits = candidate => candidate.left >= boundary.left && candidate.top >= boundary.top && candidate.left + panel.width <= boundary.right && candidate.top + panel.height <= boundary.bottom;
|
|
33
|
+
const selected = order.find(value => fits(candidates[value]));
|
|
34
|
+
if (selected) return { placement: selected, ...candidates[selected], maxHeight: Math.max(120, boundary.bottom - candidates[selected].top), constrained: false };
|
|
35
|
+
const placement = order.at(-1), candidate = candidates[placement], maximumLeft = Math.max(boundary.left, boundary.right - panel.width), maximumTop = Math.max(boundary.top, boundary.bottom - panel.height);
|
|
36
|
+
const left = Math.max(boundary.left, Math.min(maximumLeft, candidate.left)), top = Math.max(boundary.top, Math.min(maximumTop, candidate.top));
|
|
37
|
+
return { placement, left, top, maxHeight: Math.max(120, boundary.bottom - top), constrained: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function chartSettingsPlacementCoordinates(anchor, panel, placement, gap = 10) {
|
|
41
|
+
if (placement === 'right') return { left: anchor.right + gap, top: anchor.top };
|
|
42
|
+
if (placement === 'top') return { left: anchor.right - panel.width, top: anchor.top - panel.height - gap };
|
|
43
|
+
return { left: anchor.right - panel.width, top: anchor.bottom + gap };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isChartSettingsAnchorVisible(anchor, host, viewport) {
|
|
47
|
+
const boundary = { left: viewport.left || 0, top: viewport.top || 0, right: (viewport.left || 0) + viewport.width, bottom: (viewport.top || 0) + viewport.height };
|
|
48
|
+
const intersects = rect => rect && rect.right > boundary.left && rect.left < boundary.right && rect.bottom > boundary.top && rect.top < boundary.bottom;
|
|
49
|
+
return intersects(host) && intersects(anchor);
|
|
50
|
+
}
|
|
51
|
+
|
|
28
52
|
function ensureStyle() {
|
|
29
53
|
if (typeof document === 'undefined' || document.getElementById(styleId)) return;
|
|
30
54
|
const style = document.createElement('style');
|
|
31
55
|
style.id = styleId;
|
|
32
|
-
style.textContent = `.ichart-preferences{position:absolute;top:8px;right:8px;left:8px;height:32px;z-index:20;pointer-events:none;font:12px/1.4 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033}.ichart-preferences__button{position:absolute;top:0;right:0;display:grid;place-items:center;width:32px;height:32px;min-width:32px;min-height:32px;border:0;background:transparent;color:var(--ichart-preferences-icon-color,#172033);border-radius:7px;padding:0;pointer-events:auto;cursor:pointer;font-size:22px;line-height:1;text-shadow:0 1px 2px #0004}.ichart-preferences__panel{display:none;position:
|
|
56
|
+
style.textContent = `.ichart-preferences{position:absolute;top:8px;right:8px;left:8px;height:32px;z-index:20;pointer-events:none;font:12px/1.4 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033}.ichart-preferences__button{position:absolute;top:0;right:0;display:grid;place-items:center;width:32px;height:32px;min-width:32px;min-height:32px;border:0;background:transparent;color:var(--ichart-preferences-icon-color,#172033);border-radius:7px;padding:0;pointer-events:auto;cursor:pointer;font-size:22px;line-height:1;text-shadow:0 1px 2px #0004}.ichart-preferences__panel{display:none;position:fixed;z-index:10000;width:min(320px,calc(100vw - 16px));padding:12px;border:0;border-radius:10px;background:#fff;color:#172033;pointer-events:auto;overflow-y:auto;box-shadow:0 12px 30px #1720332b;font:12px/1.4 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.ichart-preferences__panel[data-open="true"]{display:block}.ichart-preferences__grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.ichart-preferences__field{display:grid;min-width:0;gap:3px;margin:0}.ichart-preferences__field>span{color:#475467}.ichart-preferences__field select{width:100%;min-width:0;height:32px;border:1px solid #c5cfdb;border-radius:6px;padding:4px 7px;background:#fff;color:inherit}.ichart-preferences__toggles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin-top:8px;padding-top:8px;border-top:1px solid #e2e8f0}.ichart-preferences__toggle{display:flex;align-items:center;justify-content:center;gap:5px;min-height:32px;padding:5px 6px;color:#344054;cursor:pointer}.ichart-preferences__toggle input{width:auto;min-height:auto;margin:0;accent-color:#2563eb}@media(max-width:420px){.ichart-preferences__panel{width:min(288px,calc(100vw - 16px))}.ichart-preferences__grid,.ichart-preferences__toggles{grid-template-columns:1fr}.ichart-preferences__toggle{justify-content:flex-start}}`;
|
|
33
57
|
document.head.appendChild(style);
|
|
34
58
|
}
|
|
35
59
|
|
|
36
|
-
export function mountChartSettings(chart, { container = chart?.container, locale = 'auto', title } = {}) {
|
|
60
|
+
export function mountChartSettings(chart, { container = chart?.container, locale = 'auto', title, placement = 'auto', preferredPlacements = placements } = {}) {
|
|
37
61
|
if (typeof document === 'undefined' || !chart || !container) return { valid: false, code: 'PREFERENCES_UI_UNAVAILABLE', message: 'Chart settings require a browser container.' };
|
|
38
62
|
ensureStyle();
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const
|
|
63
|
+
const resolvedLocale = localeKey(locale), text = messages[resolvedLocale], accessibleTitle = title || text.title, host = container;
|
|
64
|
+
const capabilities = getPreferenceCapabilities(chart.getSpec().type, { locale: resolvedLocale }), fields = new Map(capabilities.fields.map(field => [field.path, field]));
|
|
65
|
+
const modeField = fields.get('theme.mode'), paletteField = fields.get('theme.palette'), scaleField = fields.get('typography.scale');
|
|
66
|
+
const visibleComponents = ['legend', 'labels', 'grid'].filter(name => fields.get(`components.${name}`)?.menu.visible);
|
|
42
67
|
if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
|
|
43
68
|
const root = document.createElement('div');
|
|
44
69
|
root.className = 'ichart-preferences';
|
|
45
70
|
root.dataset.open = 'false';
|
|
46
|
-
const componentMarkup = visibleComponents.map(name => `<label class="ichart-preferences__toggle"><input type="checkbox" data-key="${name}"><span>${
|
|
47
|
-
root.innerHTML = `<button class="ichart-preferences__button" type="button" aria-expanded="false"><span aria-hidden="true">≡</span></button
|
|
48
|
-
const
|
|
49
|
-
|
|
71
|
+
const componentMarkup = visibleComponents.map(name => `<label class="ichart-preferences__toggle"><input type="checkbox" data-key="${name}"><span>${fields.get(`components.${name}`).label}</span></label>`).join('');
|
|
72
|
+
root.innerHTML = `<button class="ichart-preferences__button" type="button" aria-expanded="false"><span aria-hidden="true">≡</span></button>`;
|
|
73
|
+
const panel = document.createElement('div');
|
|
74
|
+
panel.className = 'ichart-preferences__panel'; panel.id = `ichart-preferences-panel-${++panelSequence}`; panel.dataset.open = 'false'; panel.setAttribute('role', 'dialog');
|
|
75
|
+
panel.innerHTML = `<div class="ichart-preferences__grid"><label class="ichart-preferences__field"><span>${modeField.label}</span><select data-key="mode">${optionMarkup(modeField)}</select></label><label class="ichart-preferences__field"><span>${paletteField.label}</span><select data-key="palette">${optionMarkup(paletteField)}</select></label><label class="ichart-preferences__field"><span>${scaleField.label}</span><select data-key="scale">${optionMarkup(scaleField)}</select></label></div>${componentMarkup ? `<div class="ichart-preferences__toggles">${componentMarkup}</div>` : ''}`;
|
|
76
|
+
const button = root.querySelector('.ichart-preferences__button');
|
|
77
|
+
button.setAttribute('aria-label', accessibleTitle); button.setAttribute('aria-controls', panel.id); button.title = accessibleTitle; panel.setAttribute('aria-label', accessibleTitle);
|
|
50
78
|
host.appendChild(root);
|
|
51
|
-
|
|
79
|
+
document.body.appendChild(panel);
|
|
80
|
+
const values = key => panel.querySelector(`[data-key="${key}"]`);
|
|
52
81
|
const readField = key => {
|
|
53
82
|
const control = values(key), value = control?.type === 'checkbox' ? control.checked : control?.value;
|
|
54
83
|
if (['mode', 'palette'].includes(key)) return { theme: { [key]: value === 'auto' ? null : value } };
|
|
@@ -67,20 +96,40 @@ export function mountChartSettings(chart, { container = chart?.container, locale
|
|
|
67
96
|
if (values('labels')) values('labels').checked = Boolean(spec.labels?.enabled);
|
|
68
97
|
if (values('grid')) values('grid').checked = spec.grid?.visible !== false;
|
|
69
98
|
};
|
|
70
|
-
const close = () => { root.dataset.open = 'false'; button.setAttribute('aria-expanded', 'false'); };
|
|
99
|
+
const close = () => { root.dataset.open = 'false'; panel.dataset.open = 'false'; button.setAttribute('aria-expanded', 'false'); };
|
|
100
|
+
const placementOrder = placement === 'auto' ? preferredPlacements : [placement, ...preferredPlacements.filter(value => value !== placement)];
|
|
101
|
+
const positionPanel = ({ preservePlacement = false } = {}) => {
|
|
102
|
+
if (panel.dataset.open !== 'true') return;
|
|
103
|
+
const visual = globalThis.visualViewport, viewport = { left: visual?.offsetLeft || 0, top: visual?.offsetTop || 0, width: visual?.width || globalThis.innerWidth, height: visual?.height || globalThis.innerHeight };
|
|
104
|
+
const anchorRect = button.getBoundingClientRect();
|
|
105
|
+
if (!isChartSettingsAnchorVisible(anchorRect, host.getBoundingClientRect(), viewport)) { close(); return; }
|
|
106
|
+
const currentPlacement = panel.dataset.placement;
|
|
107
|
+
if (preservePlacement && placements.includes(currentPlacement)) {
|
|
108
|
+
const coordinates = chartSettingsPlacementCoordinates(anchorRect, panel.getBoundingClientRect(), currentPlacement);
|
|
109
|
+
panel.style.left = `${Math.round(coordinates.left)}px`; panel.style.top = `${Math.round(coordinates.top)}px`;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
panel.style.visibility = 'hidden'; panel.style.left = '0px'; panel.style.top = '0px'; panel.style.maxHeight = '';
|
|
113
|
+
const result = resolveChartSettingsPlacement(anchorRect, panel.getBoundingClientRect(), viewport, placementOrder);
|
|
114
|
+
panel.dataset.placement = result.placement; panel.dataset.constrained = String(result.constrained); panel.style.left = `${Math.round(result.left)}px`; panel.style.top = `${Math.round(result.top)}px`; panel.style.maxHeight = `${Math.floor(result.maxHeight)}px`; panel.style.visibility = 'visible';
|
|
115
|
+
};
|
|
71
116
|
const updateIconColor = () => { const theme = chart.getTheme(), background = chart.getSpec().background || theme.background, darkRatio = contrastRatio('#172033', background), lightRatio = contrastRatio('#ffffff', background), color = darkRatio == null || lightRatio == null ? theme.text || '#172033' : darkRatio >= lightRatio ? '#172033' : '#ffffff'; button.style.setProperty('--ichart-preferences-icon-color', color); };
|
|
72
|
-
const onPreferencesChange = () => { sync(); updateIconColor(); };
|
|
117
|
+
const onPreferencesChange = () => { sync(); updateIconColor(); positionPanel(); };
|
|
73
118
|
const applyField = key => { try { chart.setPreferences(readField(key), { source: 'ui' }); } catch {} };
|
|
74
|
-
const onToggle = () => { const open = root.dataset.open !== 'true'; root.dataset.open = String(open); button.setAttribute('aria-expanded', String(open)); if (open) sync(); };
|
|
75
|
-
const onDocumentPointer = event => { if (!root.contains(event.target)) close(); };
|
|
119
|
+
const onToggle = () => { const open = root.dataset.open !== 'true'; root.dataset.open = String(open); panel.dataset.open = String(open); button.setAttribute('aria-expanded', String(open)); if (open) { sync(); positionPanel(); } };
|
|
120
|
+
const onDocumentPointer = event => { if (!root.contains(event.target) && !panel.contains(event.target)) close(); };
|
|
76
121
|
const onDocumentKey = event => { if (event.key === 'Escape') { close(); button.focus(); } };
|
|
122
|
+
const onViewportResize = () => positionPanel();
|
|
123
|
+
const onViewportScroll = () => positionPanel({ preservePlacement: true });
|
|
77
124
|
button.addEventListener('click', onToggle);
|
|
78
|
-
|
|
125
|
+
panel.querySelectorAll('select,input[data-key]').forEach(control => control.addEventListener('change', () => applyField(control.dataset.key)));
|
|
79
126
|
document.addEventListener('pointerdown', onDocumentPointer);
|
|
80
127
|
document.addEventListener('keydown', onDocumentKey);
|
|
128
|
+
globalThis.addEventListener?.('resize', onViewportResize); globalThis.addEventListener?.('scroll', onViewportScroll, true); globalThis.visualViewport?.addEventListener?.('resize', onViewportResize); globalThis.visualViewport?.addEventListener?.('scroll', onViewportScroll);
|
|
81
129
|
chart.on('preferenceschange', onPreferencesChange);
|
|
82
130
|
chart.on('themechange', updateIconColor);
|
|
131
|
+
const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onViewportResize); resizeObserver?.observe(host);
|
|
83
132
|
sync();
|
|
84
133
|
updateIconColor();
|
|
85
|
-
return { valid: true, element: root, locale:
|
|
134
|
+
return { valid: true, element: root, panel, locale: resolvedLocale, capabilities, destroy() { chart.off('preferenceschange', onPreferencesChange); chart.off('themechange', updateIconColor); document.removeEventListener('pointerdown', onDocumentPointer); document.removeEventListener('keydown', onDocumentKey); globalThis.removeEventListener?.('resize', onViewportResize); globalThis.removeEventListener?.('scroll', onViewportScroll, true); globalThis.visualViewport?.removeEventListener?.('resize', onViewportResize); globalThis.visualViewport?.removeEventListener?.('scroll', onViewportScroll); resizeObserver?.disconnect(); panel.remove(); root.remove(); } };
|
|
86
135
|
}
|
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,7 +22,7 @@ const defaults = {
|
|
|
21
22
|
grid: { visible: true },
|
|
22
23
|
labels: { enabled: false },
|
|
23
24
|
branding: { enabled: true },
|
|
24
|
-
interaction: { tooltip: true, hover: true, click: true, crosshair: false, zoom: false, brush: false, keyboard: true }
|
|
25
|
+
interaction: { tooltip: true, hover: true, click: true, crosshair: false, zoom: false, pan: false, brush: false, drag: false, edgeDrag: false, portConnect: false, keyboard: true }
|
|
25
26
|
,responsive: [], accessibility: { enabled: false }, editing: { enabled: false, mode: 'command', requireConfirmation: true, allowDelete: false, allowStructuralChanges: false }, xAxis: {}, yAxis: {}
|
|
26
27
|
};
|
|
27
28
|
|
|
@@ -68,7 +69,7 @@ export function normalizeSpec(input = {}) {
|
|
|
68
69
|
if (spec.branding.enabled === undefined) spec.branding = { ...spec.branding, enabled: true };
|
|
69
70
|
}
|
|
70
71
|
if (!spec.data) spec.data = { values: [] };
|
|
71
|
-
if (['flow', 'swimlane'].includes(spec.type) && spec.nodes && !spec.data.values) spec.data.values = [];
|
|
72
|
+
if (['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type) && spec.nodes && !spec.data.values) spec.data.values = [];
|
|
72
73
|
if (Array.isArray(spec.data)) spec.data = { values: spec.data };
|
|
73
74
|
if (!spec.encoding) spec.encoding = {};
|
|
74
75
|
if (['pie', 'funnel', 'gauge'].includes(spec.type) && !spec.encoding.category) spec.encoding.category = { field: 'name', type: 'category' };
|
|
@@ -76,7 +77,7 @@ export function normalizeSpec(input = {}) {
|
|
|
76
77
|
if (spec.type === 'scatter' && !input.encoding?.x) spec.encoding.x = { field: 'x', type: 'quantitative' };
|
|
77
78
|
if (spec.type === 'scatter' && !input.encoding?.y) spec.encoding.y = { field: 'y', type: 'quantitative' };
|
|
78
79
|
if (spec.type === 'heatmap') { spec.encoding.x ||= { field: 'x', type: 'category' }; spec.encoding.y ||= { field: 'y', type: 'category' }; spec.encoding.color ||= { field: 'value', type: 'quantitative' }; }
|
|
79
|
-
if (!['pie', 'funnel', 'gauge', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane'].includes(spec.type)) {
|
|
80
|
+
if (!['pie', 'funnel', 'gauge', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type)) {
|
|
80
81
|
spec.encoding.x = spec.encoding.x || { field: 'name', type: 'category' };
|
|
81
82
|
spec.encoding.y = spec.encoding.y || { field: 'value', type: 'quantitative' };
|
|
82
83
|
}
|
|
@@ -102,7 +103,12 @@ export function validateSpec(input) {
|
|
|
102
103
|
if (spec.type === 'pie' && spec.innerRadius != null && (!(Number(spec.innerRadius) >= 0) || Number(spec.innerRadius) >= 1)) errors.push({ code: 'INVALID_INNER_RADIUS', path: 'innerRadius', message: 'Pie innerRadius must be a ratio from 0 up to, but not including, 1.', suggestion: 'Use a value such as 0.55.' });
|
|
103
104
|
if (spec.type === 'radar' && (!Array.isArray(spec.indicators) || spec.indicators.length < 3)) errors.push({ code: 'INVALID_INDICATORS', path: 'indicators', message: 'Radar requires at least three indicators.', suggestion: 'Declare indicator name, field, min, and max.' });
|
|
104
105
|
if (!spec.data || !Array.isArray(spec.data.values)) errors.push({ code: 'INVALID_DATA', path: 'data.values', message: 'data.values must be an array.', suggestion: 'Pass an array of row objects.' });
|
|
105
|
-
if (['flow', 'swimlane'].includes(spec.type) && !Array.isArray(spec.nodes) && !Array.isArray(spec.data?.nodes)) errors.push({ code: 'INVALID_NODES', path: 'nodes', message:
|
|
106
|
+
if (['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type) && !Array.isArray(spec.nodes) && !Array.isArray(spec.data?.nodes)) errors.push({ code: 'INVALID_NODES', path: 'nodes', message: `${spec.type} charts require nodes.`, suggestion: 'Pass nodes on the Spec or in data.nodes.' });
|
|
107
|
+
if (['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type)) {
|
|
108
|
+
const diagramInput = { type: spec.type, ...(Array.isArray(spec.nodes) ? { nodes: spec.nodes } : {}), ...(Array.isArray(spec.data?.nodes) ? { nodes: spec.data.nodes } : {}), ...(Array.isArray(spec.edges) ? { edges: spec.edges } : {}), ...(Array.isArray(spec.data?.edges) ? { edges: spec.data.edges } : {}), ...(Array.isArray(spec.lanes) ? { lanes: spec.lanes } : {}), ...(Array.isArray(spec.groups) ? { groups: spec.groups } : {}), ...(Array.isArray(spec.layers) ? { layers: spec.layers } : {}), ...(Array.isArray(spec.boundaries) ? { boundaries: spec.boundaries } : {}), ...(spec.diagram ? { diagram: spec.diagram } : {}) };
|
|
109
|
+
const diagramValidation = validateDiagram(diagramInput);
|
|
110
|
+
diagramValidation.errors.forEach(error => errors.push({ ...error, path: error.path || 'diagram' }));
|
|
111
|
+
}
|
|
106
112
|
if (['gantt', 'timeline', 'milestone'].includes(spec.type)) {
|
|
107
113
|
const rows = spec.data.values, dateKey = spec.type === 'gantt' ? 'start/end' : 'date';
|
|
108
114
|
if (!rows.some(row => row.start || row.end || row.date)) errors.push({ code: 'INVALID_INTERVALS', path: 'data.values', message: `${spec.type} rows require ${dateKey} dates.`, suggestion: 'Add ISO date values to each row.' });
|
package/src/theme.mjs
CHANGED
|
@@ -66,7 +66,7 @@ const presets = {
|
|
|
66
66
|
};
|
|
67
67
|
|
|
68
68
|
const projectTypes = new Set(['gantt', 'timeline', 'milestone', 'burndown']);
|
|
69
|
-
const diagramTypes = new Set(['flow', 'swimlane']);
|
|
69
|
+
const diagramTypes = new Set(['flow', 'swimlane', 'architecture', 'mindmap']);
|
|
70
70
|
const structuredKeys = new Set(['preset', 'mode', 'palette', 'tokens']);
|
|
71
71
|
|
|
72
72
|
function clone(value) { return value == null ? value : JSON.parse(JSON.stringify(value)); }
|