@taylorwong/ichartjs 2.0.9 → 2.0.11
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 +12 -0
- package/README.md +8 -3
- package/docs/agent/coding-agent-integration.md +1 -1
- package/docs/agent/development/playground-plan.md +10 -0
- package/docs/agent/development/roadmap.md +1 -1
- package/docs/agent/frontend-integration.md +1 -1
- package/docs/agent/usage-scenarios.md +1 -1
- package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
- package/docs/agent/zh-CN/frontend-integration.md +1 -1
- package/docs/agent/zh-CN/usage-scenarios.md +1 -1
- package/package.json +1 -1
- package/skills/ichartjs/SKILL.md +4 -4
- package/src/charts.mjs +22 -12
- package/src/diagram.mjs +24 -2
- package/src/index.mjs +1 -1
- package/src/project.mjs +25 -12
- package/src/theme.mjs +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.11 - 2026-09-21
|
|
4
|
+
|
|
5
|
+
- Improved promotional chart composition by expanding the internal plotting areas for trend, delivery, and architecture examples without enlarging their cards.
|
|
6
|
+
- Hardened Architecture orthogonal routing with visible bends that preserve connection sides and avoid lines entering node rectangles.
|
|
7
|
+
- Synchronized Runtime, Playground, installation guidance, Skill pins, roadmap status, and the animated GitHub promotional asset.
|
|
8
|
+
|
|
9
|
+
## 2.0.10 - 2026-09-21
|
|
10
|
+
|
|
11
|
+
- Fixed Architecture orthogonal edges so arrows preserve facing sides, use visible bends, and avoid overlapping arrow tips.
|
|
12
|
+
- Made project label reserves follow the rendered label font size and synchronized System thinking and Delivery planning typography.
|
|
13
|
+
- Refreshed the GitHub promotional Playground page and animated GIF, including cache-busted preview loading.
|
|
14
|
+
|
|
3
15
|
## 2.0.9 - 2026-09-20
|
|
4
16
|
|
|
5
17
|
- Fixed Radar normalization so normalized Specs validate and render without unsupported Cartesian encodings.
|
package/README.md
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# iChart.js 2.0
|
|
2
2
|
|
|
3
|
-
iChart.js is an Agent-first, renderer-independent visualization runtime. An Agent can inspect data, choose an appropriate chart, generate and validate a JSON-friendly Spec, render it with SVG or Canvas, and explain the result through public APIs.
|
|
3
|
+
iChart.js 2.0 is an Agent-first, renderer-independent visualization runtime. An Agent can inspect data, choose an appropriate chart, generate and validate a JSON-friendly Spec, render it with SVG or Canvas, and explain the result through public APIs.
|
|
4
4
|
|
|
5
5
|
Use it in one of three ways: integrate the Runtime into a web project, let a Coding Agent modify a project, or install the official Skill so an Agent can orchestrate chart selection and delivery. The Runtime produces interactive pages, SVG/PNG/JPEG artifacts, JSON checkpoints, and integration code; the Skill does not replace the Runtime.
|
|
6
6
|
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img src="docs/assets/github-promo.gif" alt="iChart.js Agent-first chart workflow" width="1280">
|
|
9
|
+
</p>
|
|
10
|
+
|
|
7
11
|
## For AI Agents
|
|
8
12
|
|
|
9
13
|
Do not guess chart types or configuration fields from source code. Use the public planning contract:
|
|
@@ -40,7 +44,7 @@ getCapabilities
|
|
|
40
44
|
npm install @taylorwong/ichartjs@^2
|
|
41
45
|
```
|
|
42
46
|
|
|
43
|
-
As a fallback for environments without npm access, install directly from GitHub: `npm install github:wanghetommy/ichartjs#v2.0.
|
|
47
|
+
As a fallback for environments without npm access, install directly from GitHub: `npm install github:wanghetommy/ichartjs#v2.0.11`.
|
|
44
48
|
|
|
45
49
|
### Optional Agent Skill
|
|
46
50
|
|
|
@@ -62,7 +66,7 @@ For a non-interactive global Codex installation:
|
|
|
62
66
|
npx skills add wanghetommy/ichartjs --skill ichartjs --agent codex --global --yes
|
|
63
67
|
```
|
|
64
68
|
|
|
65
|
-
For a release-pinned installation, use `npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.
|
|
69
|
+
For a release-pinned installation, use `npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.11/skills/ichartjs --agent codex --global --yes`. WorkBuddy users can import the same tagged `skills/ichartjs` URL through the host's Skill interface; do not assume a `--agent workbuddy` adapter unless the installed CLI declares it. Package consumers can still copy `node_modules/@taylorwong/ichartjs/skills/ichartjs` as a manual fallback. After installation, invoke `$ichartjs` when named Skill invocation is supported, or select `ichartjs` in the host UI.
|
|
66
70
|
|
|
67
71
|
### Agent workflow
|
|
68
72
|
|
|
@@ -148,6 +152,7 @@ npm run playground
|
|
|
148
152
|
```
|
|
149
153
|
|
|
150
154
|
- Playground Home: `http://localhost:3000/playground/index.html`
|
|
155
|
+
- GitHub Promo: `http://localhost:3000/playground/github-promo.html`
|
|
151
156
|
- Agent Workbench: `http://localhost:3000/playground/agent-workbench.html`
|
|
152
157
|
- Complete Gallery: `http://localhost:3000/playground/project-gallery.html`
|
|
153
158
|
- Foundational Gallery: `http://localhost:3000/playground/foundational-gallery.html`
|
|
@@ -33,7 +33,7 @@ Install the official Skill with the standard Agent Skills CLI:
|
|
|
33
33
|
npx skills add wanghetommy/ichartjs --skill ichartjs
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
For global non-interactive Codex setup, append `--agent codex --global --yes`. To pin the released workflow, install `https://github.com/wanghetommy/ichartjs/tree/v2.0.
|
|
36
|
+
For global non-interactive Codex setup, append `--agent codex --global --yes`. To pin the released workflow, install `https://github.com/wanghetommy/ichartjs/tree/v2.0.11/skills/ichartjs`. WorkBuddy can import that tagged directory through its Skill interface; only use a host-specific `--agent` value when the installed CLI declares it.
|
|
37
37
|
|
|
38
38
|
Verify discovery with `npx skills add wanghetommy/ichartjs --list`; the result should include `ichartjs`.
|
|
39
39
|
|
|
@@ -14,6 +14,7 @@ The Playground is not a collection of iteration snapshots. Active pages are orga
|
|
|
14
14
|
| --- | --- | --- |
|
|
15
15
|
| `index.html` | Unified Playground home and acceptance entry point. | Runtime version, available chart count, links grouped by catalog/workbench/lab, server instructions, page readiness status, and exact acceptance URLs. |
|
|
16
16
|
| `project-gallery.html` | Canonical full catalog and first visual smoke test for all 16 public chart types. Keep this URL stable despite the historical filename. | Search and category filters, every public chart type, SVG/Canvas coverage, theme and viewport controls, enlarged preview, visible initialization status, Spec inspection, diagnostics, and links to specialized pages. |
|
|
17
|
+
| `github-promo.html` | English 16:9 promotional surface for the GitHub README and future GIF capture. | Brand message, real Runtime-rendered charts, Agent workflow sequence, English chart settings, and a stable screenshot composition. |
|
|
17
18
|
| `foundational-gallery.html` | Focused regression gallery for reusable foundational features introduced in Iteration 7. | Grouped/stacked/percent charts, Donut, Combo, Histogram/Bin, Heatmap, Radar, renderer coverage, source record identity, and expected warnings. |
|
|
18
19
|
|
|
19
20
|
### Agent and Business Workbenches
|
|
@@ -138,6 +139,15 @@ Start the cache-safe preview server with `npm run playground` before opening the
|
|
|
138
139
|
- Accessibility Lab: `http://localhost:3000/playground/accessibility-lab.html`
|
|
139
140
|
- Performance Lab: `http://localhost:3000/playground/performance-lab.html`
|
|
140
141
|
|
|
142
|
+
## GitHub Promo GIF
|
|
143
|
+
|
|
144
|
+
`docs/assets/github-promo.gif` is a release-facing README asset generated from the stable English page `playground/github-promo.html`. Refresh it whenever the page composition, chart fixtures, workflow labels, or theme states change.
|
|
145
|
+
|
|
146
|
+
1. Start the local server with `npm run playground`.
|
|
147
|
+
2. Open `http://localhost:3000/playground/github-promo.html` and capture one frame for each `[data-step="0"]` through `[data-step="4"]` state at the same `16:9` viewport.
|
|
148
|
+
3. Convert the five frames to a looping GIF with `ffmpeg`, using a short hold per frame and a generated palette to keep the README asset compact.
|
|
149
|
+
4. Replace `docs/assets/github-promo.gif`, inspect the animation, and run `git diff --check` before release.
|
|
150
|
+
|
|
141
151
|
## Completion Definition
|
|
142
152
|
|
|
143
153
|
The Playground plan is complete when every active page has one clear owner responsibility, all stable URLs are reachable from the Home page, the full catalog and specialized workflows do not conflict, Agent behavior is independently verifiable, cross-cutting quality has dedicated labs, historical iteration pages are no longer release dependencies, and every Iteration 8 delivery report identifies an exact URL and reproducible acceptance procedure.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# iChart.js 2.0 Roadmap
|
|
2
2
|
|
|
3
|
-
> Roadmap baseline: 2026-09-14. Current release status: `v2.0.
|
|
3
|
+
> Roadmap baseline: 2026-09-14. Current release status: `v2.0.11` includes the completed Iteration 12 structured-diagram work, follow-up chart/menu fixes, hardened Agent chart contracts, and architecture/promo layout fixes. Geographic charts and 3D rendering remain out of scope until explicitly reintroduced.
|
|
4
4
|
|
|
5
5
|
## Current Status
|
|
6
6
|
|
|
@@ -10,7 +10,7 @@ This is the production component path. For one-off files or Agent-led repository
|
|
|
10
10
|
npm install @taylorwong/ichartjs@^2
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
For environments without npm registry access, install from GitHub as a fallback: `npm install github:wanghetommy/ichartjs#v2.0.
|
|
13
|
+
For environments without npm registry access, install from GitHub as a fallback: `npm install github:wanghetommy/ichartjs#v2.0.11`.
|
|
14
14
|
|
|
15
15
|
Use the package through a bundler or another environment that resolves npm ESM imports:
|
|
16
16
|
|
|
@@ -121,7 +121,7 @@ npx skills add wanghetommy/ichartjs --skill ichartjs --agent codex --global --ye
|
|
|
121
121
|
For reproducible installation, pin the released Skill directory:
|
|
122
122
|
|
|
123
123
|
```bash
|
|
124
|
-
npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.
|
|
124
|
+
npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.11/skills/ichartjs \
|
|
125
125
|
--agent codex --global --yes
|
|
126
126
|
```
|
|
127
127
|
|
|
@@ -22,7 +22,7 @@ npm Registry 中无作用域的 `ichartjs` 是安全占位包,并非本项目
|
|
|
22
22
|
npx skills add wanghetommy/ichartjs --skill ichartjs
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Codex 全局无交互安装可追加 `--agent codex --global --yes`。需要固定发布版本时,安装 `https://github.com/wanghetommy/ichartjs/tree/v2.0.
|
|
25
|
+
Codex 全局无交互安装可追加 `--agent codex --global --yes`。需要固定发布版本时,安装 `https://github.com/wanghetommy/ichartjs/tree/v2.0.11/skills/ichartjs`。WorkBuddy 可通过自身 Skill 界面导入该带 Tag 的目录;只有当前 CLI 明确声明对应适配器时才使用宿主专用 `--agent` 参数。
|
|
26
26
|
|
|
27
27
|
使用 `npx skills add wanghetommy/ichartjs --list` 验证发现结果,其中应包含 `ichartjs`。
|
|
28
28
|
|
|
@@ -6,7 +6,7 @@ iChart.js 应作为普通 JavaScript UI 组件运行在浏览器应用中。数
|
|
|
6
6
|
npm install @taylorwong/ichartjs@^2
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
无法访问 npm Registry 的环境请用 GitHub 源作为后备:`npm install github:wanghetommy/ichartjs#v2.0.
|
|
9
|
+
无法访问 npm Registry 的环境请用 GitHub 源作为后备:`npm install github:wanghetommy/ichartjs#v2.0.11`。
|
|
10
10
|
|
|
11
11
|
```js
|
|
12
12
|
import { createChart } from '@taylorwong/ichartjs';
|
|
@@ -116,7 +116,7 @@ npx skills add wanghetommy/ichartjs --skill ichartjs --agent codex --global --ye
|
|
|
116
116
|
需要固定发布版本时,直接安装已发布的 Skill 目录:
|
|
117
117
|
|
|
118
118
|
```bash
|
|
119
|
-
npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.
|
|
119
|
+
npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.11/skills/ichartjs \
|
|
120
120
|
--agent codex --global --yes
|
|
121
121
|
```
|
|
122
122
|
|
package/package.json
CHANGED
package/skills/ichartjs/SKILL.md
CHANGED
|
@@ -21,7 +21,7 @@ Recommended installation:
|
|
|
21
21
|
npx skills add wanghetommy/ichartjs --skill ichartjs
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Use `--agent codex --global --yes` for global non-interactive Codex installation. Use the tagged directory `https://github.com/wanghetommy/ichartjs/tree/v2.0.
|
|
24
|
+
Use `--agent codex --global --yes` for global non-interactive Codex installation. Use the tagged directory `https://github.com/wanghetommy/ichartjs/tree/v2.0.11/skills/ichartjs` when reproducibility matters. WorkBuddy can import the same directory through its Skill interface; do not assume a `--agent workbuddy` adapter unless the installed CLI declares it.
|
|
25
25
|
|
|
26
26
|
The Skill is a workflow adapter, not the chart runtime. If the current JavaScript or TypeScript project does not already depend on iChart.js, install the matching runtime from GitHub:
|
|
27
27
|
|
|
@@ -45,9 +45,9 @@ Do not install the unscoped npm registry package named `ichartjs`; it is current
|
|
|
45
45
|
10. Call `validateSpec()` before rendering. Repair `UNSUPPORTED_ENCODING_CHANNEL`, `MISSING_ENCODING_FIELD`, `MISSING_GAUGE_DOMAIN`, `UNKNOWN_INTENT`, misplaced-option, and unsupported-axis warnings before presenting the chart.
|
|
46
46
|
11. Call `createChart()` only after validation succeeds. Gauge Specs must declare a meaningful `domain`.
|
|
47
47
|
12. Self-check with `chart.explain()`, `chart.getState()`, `health.renderable`, and JSON export. Treat `VALUE_CLAMPED`, `LABELS_SUPPRESSED`, `NEGATIVE_VALUE_DROPPED`, and `ZERO_TOTAL` as material diagnostics to report.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
13. Provide an exact preview URL or artifact path and report assumptions, warnings, and deferred checks.
|
|
49
|
+
14. Prefer `theme: { mode: 'auto', preset, palette }`; preserve explicit user style choices and use `chart.setTheme()` for live switching.
|
|
50
|
+
15. For post-creation visual changes, call `getPreferenceCapabilities(chartType, { locale })`, validate the patch with `validatePreferences()`, apply it with `chart.setPreferences(..., { source: 'agent' })`, and verify `chart.getState().preferences`.
|
|
51
51
|
|
|
52
52
|
Use `@taylorwong/ichartjs` for package imports. Use `examples/agent-workflow.mjs` as the executable baseline when working in the repository.
|
|
53
53
|
|
package/src/charts.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { normalizeData } from './data.mjs';
|
|
|
7
7
|
import { applyTransforms } from './transforms.mjs';
|
|
8
8
|
import { buildProjectScene } from './project.mjs';
|
|
9
9
|
import { formatValue } from './format.mjs';
|
|
10
|
-
import { resolveTheme } from './theme.mjs';
|
|
10
|
+
import { contrastRatio, resolveTheme } from './theme.mjs';
|
|
11
11
|
import { axisLabelLayout, boxesOverlap, estimateTextWidth, fontSize, titleLayout } from './layout.mjs';
|
|
12
12
|
|
|
13
13
|
const pick = (row, encoding, fallback) => row?.[encoding?.field || fallback];
|
|
@@ -111,9 +111,10 @@ function layout(spec, data, legendEntries = []) {
|
|
|
111
111
|
const categoryLabelLength = spec.type === 'bar' ? Math.max(0, ...data.rows.map(row => String(row[categoryField] ?? '').length)) : 0;
|
|
112
112
|
const matrixYField = spec.encoding.y?.field || 'y', matrixLabelLength = spec.type === 'heatmap' ? Math.max(0, ...data.rows.map(row => String(row[matrixYField] ?? '').length)) : 0;
|
|
113
113
|
const sideLabelLength = Math.max(categoryLabelLength, matrixLabelLength);
|
|
114
|
-
const plotLeft = ['bar', 'heatmap'].includes(spec.type) ? Math.min(width * 0.35, Math.max(p.left, sideLabelLength * fontSize(spec, 'axis', 12) * 0.62 + 24)) : p.left;
|
|
115
|
-
const
|
|
116
|
-
const
|
|
114
|
+
const centeredPlot = ['pie', 'funnel', 'gauge', 'radar'].includes(spec.type), plotLeft = centeredPlot ? Math.max(p.left, p.right) : ['bar', 'heatmap'].includes(spec.type) ? Math.min(width * 0.35, Math.max(p.left, sideLabelLength * fontSize(spec, 'axis', 12) * 0.62 + 24)) : p.left, plotRight = centeredPlot ? plotLeft : p.right;
|
|
115
|
+
const labelTopReserve = spec.type === 'column' && spec.labels?.enabled ? Math.ceil(fontSize(spec, 'label', 12) * 1.5 + 4) : 0;
|
|
116
|
+
const plotTop = Math.max(p.top, chrome.bottom + (chrome.bottom ? 12 : 0)) + labelTopReserve;
|
|
117
|
+
const plotWidth = Math.max(1, width - plotLeft - plotRight);
|
|
117
118
|
const axisLabelTexts = temporal ? timeTicks(xMin, xMax, 5).map(value => formatTime(value, xMax - xMin, spec.xAxis?.format, spec.locale)) : quantitativeX ? timeTicks(xMin, xMax, 5).map(value => formatValue(value, spec.xAxis?.format, spec.locale)) : categories;
|
|
118
119
|
const xLabels = axisLabelLayout(spec, spec.type === 'bar' ? [] : axisLabelTexts, plotWidth);
|
|
119
120
|
const bottomReserve = Math.max(p.bottom, Math.ceil(16 + xLabels.projectedHeight + (spec.xAxis?.title ? 20 : 0)));
|
|
@@ -190,12 +191,21 @@ function legendEntries(spec, data, series, colors) {
|
|
|
190
191
|
return [];
|
|
191
192
|
}
|
|
192
193
|
|
|
193
|
-
function
|
|
194
|
+
function readableLabelColor(spec, background) {
|
|
195
|
+
const preferred = spec.labels?.color || spec.theme.text;
|
|
196
|
+
const candidates = [preferred, spec.theme.text, spec.theme.background, '#ffffff', '#172033'];
|
|
197
|
+
return candidates.find(color => (contrastRatio(color, background) || 0) >= 4.5) || preferred;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function addMarkLabel(scene, spec, id, value, x, y, dataRef, bounds = null, anchor = null, formatOverride = null, labelBackground = null) {
|
|
194
201
|
if (!spec.labels?.enabled) return;
|
|
195
202
|
const text = formatValue(value, formatOverride || spec.labels.format, spec.locale), size = fontSize(spec, 'label', 12), width = estimateTextWidth(text, size), height = size * 1.25;
|
|
196
|
-
const
|
|
203
|
+
const baseArea = bounds || scene._labelArea || { x: 0, y: 0, width: spec.width, height: spec.height };
|
|
204
|
+
const area = spec.type === 'column' && !anchor ? { ...baseArea, y: baseArea.y - height - 4, height: baseArea.height + height + 4 } : baseArea;
|
|
205
|
+
const markBackground = spec.type === 'column' && !anchor && y - height / 2 < area.y ? spec.colors?.[dataRef?.seriesIndex || 0] : null;
|
|
206
|
+
const labelY = markBackground ? y + height + 8 : spec.type === 'column' && !anchor ? y - height / 2 - 4 : y;
|
|
197
207
|
const safeX = Math.max(area.x + width / 2, Math.min(area.x + area.width - width / 2, x));
|
|
198
|
-
const safeY = Math.max(area.y + height / 2, Math.min(area.y + area.height - height / 2,
|
|
208
|
+
const safeY = Math.max(area.y + height / 2, Math.min(area.y + area.height - height / 2, labelY));
|
|
199
209
|
const anchorRadius = Number(anchor?.radius) || 0, anchorGap = Number(anchor?.gap) || 6;
|
|
200
210
|
const anchorBox = anchor ? { x: anchor.x - anchorRadius - anchorGap, y: anchor.y - anchorRadius - anchorGap, width: anchorRadius * 2 + anchorGap * 2, height: anchorRadius * 2 + anchorGap * 2 } : null;
|
|
201
211
|
const preferred = anchor ? anchor.y - anchorRadius - anchorGap - height / 2 : safeY;
|
|
@@ -205,7 +215,7 @@ function addMarkLabel(scene, spec, id, value, x, y, dataRef, bounds = null, anch
|
|
|
205
215
|
const placement = shifts.map(candidateY => Math.max(area.y + height / 2, Math.min(area.y + area.height - height / 2, candidateY))).map(candidateY => ({ x: safeX, y: candidateY, box: { x: safeX - width / 2, y: candidateY - height / 2, width, height } })).find(candidate => !occupied.some(box => boxesOverlap(candidate.box, box, 2)) && (!anchorBox || !boxesOverlap(candidate.box, anchorBox, 0)));
|
|
206
216
|
if (!placement) return;
|
|
207
217
|
occupied.push(placement.box);
|
|
208
|
-
addText(scene, `${id}-label`, text, placement.x, placement.y, { fill: spec.labels.color || spec.theme.text, font: spec.labels.font || font(spec, 'label'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }, dataRef);
|
|
218
|
+
addText(scene, `${id}-label`, text, placement.x, placement.y, { fill: labelBackground || markBackground ? readableLabelColor(spec, labelBackground || markBackground) : spec.labels.color || spec.theme.text, font: spec.labels.font || font(spec, 'label'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }, dataRef);
|
|
209
219
|
}
|
|
210
220
|
|
|
211
221
|
function hexRgb(value) { const hex = String(value || '').replace('#', ''); if (!/^[0-9a-f]{6}$/i.test(hex)) return [37, 99, 235]; return [0, 2, 4].map(index => parseInt(hex.slice(index, index + 2), 16)); }
|
|
@@ -223,7 +233,7 @@ function addHeatmapScene(scene, spec, data, state, colors) {
|
|
|
223
233
|
const yStep = Math.max(1, Math.ceil(fontSize(spec, 'axis', 12) * 1.35 / Math.max(1, height)));
|
|
224
234
|
yValues.forEach((value, index) => { if (index % yStep !== 0 && index !== yValues.length - 1) return; addText(scene, `heatmap-y-${index}`, value, state.plot.x - 10, state.plot.y + (index + .5) * height, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'end', textBaseline: 'middle', baseline: 'middle' }); });
|
|
225
235
|
let hiddenLabels = 0, visibleLabels = 0;
|
|
226
|
-
data.rows.forEach((row, index) => { const xIndex = xValues.indexOf(String(row[xField])), yIndex = yValues.indexOf(String(row[yField])), value = numericValue(row), ratio = Number.isFinite(value) ? (value - min) / (max - min || 1) : 0, geometry = { x: state.plot.x + xIndex * width + gap / 2, y: state.plot.y + yIndex * height + gap / 2, width: Math.max(1, width - gap), height: Math.max(1, height - gap) }; scene.add(new SceneNode({ id: `heatmap-cell-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill:
|
|
236
|
+
data.rows.forEach((row, index) => { const xIndex = xValues.indexOf(String(row[xField])), yIndex = yValues.indexOf(String(row[yField])), value = numericValue(row), ratio = Number.isFinite(value) ? (value - min) / (max - min || 1) : 0, cellFill = Number.isFinite(value) ? colorMix(low, high, ratio) : missing, geometry = { x: state.plot.x + xIndex * width + gap / 2, y: state.plot.y + yIndex * height + gap / 2, width: Math.max(1, width - gap), height: Math.max(1, height - gap) }; scene.add(new SceneNode({ id: `heatmap-cell-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: cellFill, stroke: spec.theme.background, strokeWidth: 1 }, dataRef: { dataIndex: index, x: row[xField], y: row[yField], value: Number.isFinite(value) ? value : null, recordId: row.id || `record-${index}` }, interactive: true })); if (spec.labels?.enabled && Number.isFinite(value)) { const label = formatValue(value, spec.labels.format, spec.locale), size = fontSize(spec, 'label', 12); if (geometry.width >= estimateTextWidth(label, size) + 8 && geometry.height >= size * 1.35) { addText(scene, `heatmap-cell-label-${index}`, label, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2, { fill: readableLabelColor(spec, cellFill), font: spec.labels.font || font(spec, 'label'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }); visibleLabels += 1; } else hiddenLabels += 1; } });
|
|
227
237
|
state.labelLayout = { ...(state.labelLayout || {}), heatmap: { hidden: hiddenLabels, visible: visibleLabels } };
|
|
228
238
|
if (hiddenLabels) data.warnings.push({ code: 'LABELS_SUPPRESSED', path: 'labels', count: hiddenLabels, message: `${hiddenLabels} heatmap labels were suppressed because cells are too small.`, suggestion: 'Increase chart width or height, reduce the matrix, or disable labels.' });
|
|
229
239
|
state.matrix = { xValues, yValues, min, max };
|
|
@@ -352,7 +362,7 @@ export function buildScene(spec) {
|
|
|
352
362
|
else if (spec.type === 'radar') addRadarScene(scene, spec, data, state, colors);
|
|
353
363
|
else if (spec.type === 'line' || spec.type === 'area') {
|
|
354
364
|
const mode = typeof spec.stack === 'string' ? spec.stack : spec.stack?.mode, accumulators = data.rows.map(() => ({ positive: 0, negative: 0 }));
|
|
355
|
-
series.forEach((encoding, seriesIndex) => { const yMap = seriesIndex === 1 && !mode ? state.yRight : state.y, values = data.rows.map(row => Number(row[encoding.field])), totals = data.rows.map((row, index) => series.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || Math.abs(values[index]) || 1); const points = [], bases = []; data.rows.forEach((row, index) => { let value = values[index]; if (!Number.isFinite(value)) return; if (mode === 'percent') value /= totals[index]; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = mode ? start + value : value; if (mode) accumulators[index][accumulator] = end; points.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(end), row, index, value }); bases.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(start) }); }); if (spec.type === 'area' && points.length) scene.add(new SceneNode({ id: `area-fill-${seriesIndex}`, type: 'path', geometry: { points: [...bases.slice().reverse(), ...points.map(point => ({ x: point.x, y: point.y }))], closed: true }, style: { fill: `${colors[seriesIndex]}33`, stroke: 'transparent' } })); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points, closed: false }, style: { stroke: colors[seriesIndex], strokeWidth: 2, fill: 'none' } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 5 }, bounds: { x: point.x - 8, y: point.y - 8, width: 16, height: 16 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.
|
|
365
|
+
series.forEach((encoding, seriesIndex) => { const yMap = seriesIndex === 1 && !mode ? state.yRight : state.y, values = data.rows.map(row => Number(row[encoding.field])), totals = data.rows.map((row, index) => series.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || Math.abs(values[index]) || 1); const points = [], bases = []; data.rows.forEach((row, index) => { let value = values[index]; if (!Number.isFinite(value)) return; if (mode === 'percent') value /= totals[index]; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = mode ? start + value : value; if (mode) accumulators[index][accumulator] = end; points.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(end), labelY: mode ? (yMap(start) + yMap(end)) / 2 : yMap(end) - 8, row, index, value }); bases.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(start) }); }); if (spec.type === 'area' && points.length) scene.add(new SceneNode({ id: `area-fill-${seriesIndex}`, type: 'path', geometry: { points: [...bases.slice().reverse(), ...points.map(point => ({ x: point.x, y: point.y }))], closed: true }, style: { fill: `${colors[seriesIndex]}33`, stroke: 'transparent' } })); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points, closed: false }, style: { stroke: colors[seriesIndex], strokeWidth: 2, fill: 'none' } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 5 }, bounds: { x: point.x - 8, y: point.y - 8, width: 16, height: 16 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.labelY, dataRef, state.plot, mode ? null : { x: point.x, y: point.y, radius: 5 }); }); });
|
|
356
366
|
} else if (spec.type === 'bar' || spec.type === 'column') {
|
|
357
367
|
const band = state.plot.width / Math.max(1, state.categories.length), bar = band * 0.58;
|
|
358
368
|
const mode = typeof spec.stack === 'string' ? spec.stack : spec.stack?.mode, accumulators = data.rows.map(() => ({ positive: 0, negative: 0 }));
|
|
@@ -363,10 +373,10 @@ export function buildScene(spec) {
|
|
|
363
373
|
const categoryField = spec.encoding.category?.field || 'name', valueField = spec.encoding.value?.field || 'value', values = data.rows.map(row => Number(row[valueField])), total = values.reduce((sum, value) => sum + (Number.isFinite(value) && value > 0 ? value : 0), 0), negativeCount = values.filter(value => Number.isFinite(value) && value < 0).length, cx = state.plot.x + state.plot.width / 2, cy = state.plot.y + state.plot.height / 2, radius = Math.min(state.plot.width, state.plot.height) * 0.38, innerR = radius * Number(spec.innerRadius || 0); let angle = -Math.PI / 2;
|
|
364
374
|
if (negativeCount) data.warnings.push({ code: 'NEGATIVE_VALUE_DROPPED', path: 'encoding.value', count: negativeCount, message: `Pie ignores ${negativeCount} negative value${negativeCount === 1 ? '' : 's'} when calculating shares.`, suggestion: 'Use non-negative part-to-whole values or choose a Cartesian chart for signed measures.' });
|
|
365
375
|
if (!(total > 0)) { data.warnings.push({ code: 'ZERO_TOTAL', path: 'encoding.value', message: 'Pie requires a positive total.', suggestion: 'Provide at least one positive value or render an explicit empty state.' }); addText(scene, 'pie-zero-total', spec.emptyText || 'No positive values', cx, cy, { fill: spec.theme.muted, font: font(spec, 'subtitle'), textAnchor: 'middle' }); }
|
|
366
|
-
else data.rows.forEach((row, index) => { const rawValue = values[index], value = Number.isFinite(rawValue) ? Math.max(0, rawValue) : 0, end = angle + value / total * Math.PI * 2, middle = angle + (end - angle) / 2, dataRef = { seriesIndex: 0, dataIndex: index, category: row[categoryField], value, rawValue }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'arc', geometry: { cx, cy, r: radius, innerR, start: angle, end }, bounds: { x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2 }, style: { fill
|
|
376
|
+
else data.rows.forEach((row, index) => { const rawValue = values[index], value = Number.isFinite(rawValue) ? Math.max(0, rawValue) : 0, end = angle + value / total * Math.PI * 2, middle = angle + (end - angle) / 2, fill = colors[index % colors.length], dataRef = { seriesIndex: 0, dataIndex: index, category: row[categoryField], value, rawValue }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'arc', geometry: { cx, cy, r: radius, innerR, start: angle, end }, bounds: { x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2 }, style: { fill, stroke: spec.theme.background, strokeWidth: 1 }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value / total, cx + Math.cos(middle) * radius * .72, cy + Math.sin(middle) * radius * .72, dataRef, state.plot, null, spec.labels?.format || { style: 'percent' }, fill); angle = end; });
|
|
367
377
|
} else if (spec.type === 'funnel') {
|
|
368
378
|
const valueField = spec.encoding.value?.field || 'value'; const maxValue = Math.max(...data.rows.map(row => Number(row[valueField]) || 0), 1); const segmentHeight = state.plot.height / data.rows.length;
|
|
369
|
-
data.rows.forEach((row, index) => { const value = Number(row[valueField]) || 0, ratio = Math.max(0.1, value / maxValue); const width = state.plot.width * ratio; const geometry = { x: state.plot.x + (state.plot.width - width) / 2, y: state.plot.y + index * segmentHeight, width, height: Math.max(2, segmentHeight - 3) }, dataRef = { seriesIndex: 0, dataIndex: index, field: valueField }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill
|
|
379
|
+
data.rows.forEach((row, index) => { const value = Number(row[valueField]) || 0, ratio = Math.max(0.1, value / maxValue), fill = colors[index % colors.length]; const width = state.plot.width * ratio; const geometry = { x: state.plot.x + (state.plot.width - width) / 2, y: state.plot.y + index * segmentHeight, width, height: Math.max(2, segmentHeight - 3) }, dataRef = { seriesIndex: 0, dataIndex: index, field: valueField }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 + 4, dataRef, state.plot, null, null, fill); });
|
|
370
380
|
} else if (spec.type === 'gauge') {
|
|
371
381
|
const valueField = spec.encoding.value?.field || 'value', domain = Array.isArray(spec.domain) ? spec.domain : [0, 100], rawValue = Number(data.rows[0]?.[valueField]), numericValue = Number.isFinite(rawValue) ? rawValue : 0, value = Math.max(Number(domain[0]), Math.min(Number(domain[1]), numericValue)), ratio = (value - Number(domain[0])) / (Number(domain[1]) - Number(domain[0]) || 1), cx = state.plot.x + state.plot.width / 2, cy = state.plot.y + state.plot.height * 0.62, radius = Math.min(state.plot.width, state.plot.height) * 0.36, start = Math.PI, end = start + Math.PI * ratio;
|
|
372
382
|
if (!Array.isArray(spec.domain)) data.warnings.push({ code: 'MISSING_GAUGE_DOMAIN', path: 'domain', message: 'Gauge rendered with a compatibility fallback; provide an explicit domain to make the value meaningful.' });
|
package/src/diagram.mjs
CHANGED
|
@@ -184,7 +184,7 @@ export function routeEdgePath(edge, from, to, mode = 'orthogonal') {
|
|
|
184
184
|
if (samples.every((point, index) => index === 0 || obstacles.every(box => !segmentIntersectsBox(samples[index - 1], point, box, padding)))) return { points, curve: 'cubic' };
|
|
185
185
|
return routeEdgePath({ ...edge, waypoints: [] }, from, to, 'orthogonal');
|
|
186
186
|
}
|
|
187
|
-
const sidePairs = edge.fromPortDefinition || edge.toPortDefinition ? [[startSide, endSide]] : [[startSide, endSide], ...['right', 'left', 'top', 'bottom'].flatMap(fromSide => ['right', 'left', 'top', 'bottom'].map(toSide => [fromSide, toSide]))].filter((pair, index, pairs) => pairs.findIndex(item => item[0] === pair[0] && item[1] === pair[1]) === index);
|
|
187
|
+
const sidePairs = edge.preserveSides || edge.fromPortDefinition || edge.toPortDefinition ? [[startSide, endSide]] : [[startSide, endSide], ...['right', 'left', 'top', 'bottom'].flatMap(fromSide => ['right', 'left', 'top', 'bottom'].map(toSide => [fromSide, toSide]))].filter((pair, index, pairs) => pairs.findIndex(item => item[0] === pair[0] && item[1] === pair[1]) === index);
|
|
188
188
|
const routeForSides = (fromSide, toSide) => {
|
|
189
189
|
const fromPort = edge.fromPortDefinition || { side: fromSide }, toPort = edge.toPortDefinition || { side: toSide }, { start, end } = diagramConnectionPoints(from, to, fromPort, toPort), padding = Math.max(12, Number(edge.grid) || 8);
|
|
190
190
|
const segmentIntersects = (first, second, box) => {
|
|
@@ -233,7 +233,29 @@ export function routeEdgePath(edge, from, to, mode = 'orthogonal') {
|
|
|
233
233
|
};
|
|
234
234
|
for (const [fromSide, toSide] of sidePairs) {
|
|
235
235
|
const route = routeForSides(fromSide, toSide);
|
|
236
|
-
if (route)
|
|
236
|
+
if (!route) continue;
|
|
237
|
+
if (!edge.preserveSides || mode !== 'orthogonal') return { points: route, curve: null };
|
|
238
|
+
const bendGap = Math.max(12, Number(edge.grid) || 8);
|
|
239
|
+
const firstSpan = Math.abs(route[1]?.x - route[0]?.x) + Math.abs(route[1]?.y - route[0]?.y);
|
|
240
|
+
const lastSpan = Math.abs(route.at(-1)?.x - route.at(-2)?.x) + Math.abs(route.at(-1)?.y - route.at(-2)?.y);
|
|
241
|
+
if (firstSpan >= bendGap && lastSpan >= bendGap) return { points: route, curve: null };
|
|
242
|
+
}
|
|
243
|
+
if (edge.preserveSides && mode === 'orthogonal') {
|
|
244
|
+
const horizontal = ['left', 'right'].includes(startSide) && ['left', 'right'].includes(endSide);
|
|
245
|
+
const vertical = ['top', 'bottom'].includes(startSide) && ['top', 'bottom'].includes(endSide);
|
|
246
|
+
const bendGap = Math.max(12, Number(edge.grid) || 8), snapFallback = value => Math.round(value / (Number(edge.grid) || 8)) * (Number(edge.grid) || 8);
|
|
247
|
+
if (horizontal) {
|
|
248
|
+
const channelY = initial.start.y < to.y + to.height / 2 ? to.y - bendGap : to.y + to.height + bendGap;
|
|
249
|
+
const sourceChannelX = startSide === 'right' ? initial.start.x + bendGap : initial.start.x - bendGap;
|
|
250
|
+
const targetChannelX = endSide === 'left' ? to.x - bendGap : to.x + to.width + bendGap;
|
|
251
|
+
return { points: compact([initial.start, { x: snapFallback(sourceChannelX), y: initial.start.y }, { x: snapFallback(sourceChannelX), y: snapFallback(channelY) }, { x: snapFallback(targetChannelX), y: snapFallback(channelY) }, { x: snapFallback(targetChannelX), y: initial.end.y }, initial.end]), curve: null };
|
|
252
|
+
}
|
|
253
|
+
if (vertical) {
|
|
254
|
+
const channelX = initial.start.x < to.x + to.width / 2 ? to.x - bendGap : to.x + to.width + bendGap;
|
|
255
|
+
const sourceChannelY = startSide === 'bottom' ? initial.start.y + bendGap : initial.start.y - bendGap;
|
|
256
|
+
const targetChannelY = endSide === 'top' ? to.y - bendGap : to.y + to.height + bendGap;
|
|
257
|
+
return { points: compact([initial.start, { x: initial.start.x, y: snapFallback(sourceChannelY) }, { x: snapFallback(channelX), y: snapFallback(sourceChannelY) }, { x: snapFallback(channelX), y: snapFallback(targetChannelY) }, { x: initial.end.x, y: snapFallback(targetChannelY) }, initial.end]), curve: null };
|
|
258
|
+
}
|
|
237
259
|
}
|
|
238
260
|
return { points: [initial.start, initial.end], curve: null };
|
|
239
261
|
}
|
package/src/index.mjs
CHANGED
|
@@ -639,4 +639,4 @@ export { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, cr
|
|
|
639
639
|
|
|
640
640
|
export { contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin };
|
|
641
641
|
export { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, mountChartSettings, normalizePreferences, validatePreferences };
|
|
642
|
-
export const iChart = { version: '2.0.
|
|
642
|
+
export const iChart = { version: '2.0.11', createChart, inspectData, normalizeData, binData, applyTransforms, data, getCapabilities, getChartCapability, getPreferenceCapabilities, planChart, recommend, explainChart, contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, createPreferencesStore, defaultPreferences, normalizePreferences, mergePreferences, validatePreferences, applyPreferencesToSpec, mountChartSettings, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries, normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };
|
package/src/project.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { layoutDiagram, normalizeDiagramData, routeEdgePath } from './diagram.mj
|
|
|
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
|
+
import { axisLabelLayout, estimateTextWidth, fontSize, titleLayout } from './layout.mjs';
|
|
12
12
|
|
|
13
13
|
export const projectTypes = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'];
|
|
14
14
|
const day = 86400000;
|
|
@@ -45,6 +45,15 @@ function projectConfig(spec) {
|
|
|
45
45
|
return spec.project || {};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function projectLabelReserve(spec, rows) {
|
|
49
|
+
const labels = spec.type === 'swimlane'
|
|
50
|
+
? (spec.lanes || []).map(lane => lane.label || lane.id)
|
|
51
|
+
: rows.map(row => row.name || row.title || row.label || row.id);
|
|
52
|
+
const labelSize = fontSize(spec, 'label', 12);
|
|
53
|
+
const widest = Math.max(0, ...labels.filter(Boolean).map(label => estimateTextWidth(label, labelSize)));
|
|
54
|
+
return Math.min(170, Math.max(spec.padding.left, Math.ceil(widest + 20)));
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
function projectOverlays(spec) {
|
|
49
58
|
const project = projectConfig(spec);
|
|
50
59
|
return {
|
|
@@ -124,7 +133,7 @@ function tasksScene(scene, spec, rows, state) {
|
|
|
124
133
|
const analyticsMap = new Map((state.schedule?.tasks || []).map(task => [task.id, task]));
|
|
125
134
|
const highlighted = new Set(spec.criticalPath === false || !overlays.criticalPath ? [] : Array.isArray(spec.criticalPath) ? spec.criticalPath : state.schedule?.criticalIds || []);
|
|
126
135
|
const selected = projectSelection(spec, state);
|
|
127
|
-
const rowHeight =
|
|
136
|
+
const rowHeight = plot.height / Math.max(1, rows.length), barHeight = Math.min(20, Math.max(10, rowHeight * 0.62)), barOffset = barHeight / 2;
|
|
128
137
|
rows.forEach((row, index) => {
|
|
129
138
|
const analytics = analyticsMap.get(row.id);
|
|
130
139
|
const recordId = linkedRecordId(row, index);
|
|
@@ -140,14 +149,14 @@ function tasksScene(scene, spec, rows, state) {
|
|
|
140
149
|
if (overlays.baseline && baselineStart) {
|
|
141
150
|
const baselineStartX = map(baselineStart), baselineEndX = map(baselineEnd || baselineStart);
|
|
142
151
|
if (milestone) scene.add({ id: `project-baseline-${index}`, type: 'circle', geometry: { cx: baselineStartX, cy: y, r: 4 }, bounds: { x: baselineStartX - 4, y: y - 4, width: 8, height: 8 }, style: { fill: spec.theme.background, stroke: spec.theme.axis, strokeWidth: 1.5 }, zIndex: 1 });
|
|
143
|
-
else scene.add({ id: `project-baseline-${index}`, type: 'rect', geometry: { x: baselineStartX, y: y -
|
|
152
|
+
else scene.add({ id: `project-baseline-${index}`, type: 'rect', geometry: { x: baselineStartX, y: y - barOffset - 4, width: Math.max(2, baselineEndX - baselineStartX), height: 4 }, style: { fill: spec.theme.border }, zIndex: 1 });
|
|
144
153
|
}
|
|
145
154
|
if (overlays.actual && actualStart) {
|
|
146
155
|
const actualStartX = map(actualStart), actualEndX = map(actualEnd || actualStart);
|
|
147
156
|
if (milestone) scene.add({ id: `project-actual-${index}`, type: 'circle', geometry: { cx: actualStartX, cy: y, r: 3 }, bounds: { x: actualStartX - 3, y: y - 3, width: 6, height: 6 }, style: { fill: spec.theme.text }, zIndex: 3 });
|
|
148
|
-
else scene.add({ id: `project-actual-${index}`, type: 'rect', geometry: { x: actualStartX, y: y +
|
|
157
|
+
else scene.add({ id: `project-actual-${index}`, type: 'rect', geometry: { x: actualStartX, y: y + barOffset + 2, width: Math.max(2, actualEndX - actualStartX), height: 3 }, style: { fill: spec.theme.text, opacity: 0.35 }, zIndex: 3 });
|
|
149
158
|
}
|
|
150
|
-
const geometry = milestone ? { cx: start, cy: y, r: 7 } : { x: start, y: y -
|
|
159
|
+
const geometry = milestone ? { cx: start, cy: y, r: Math.min(7, barOffset) } : { x: start, y: y - barOffset, width: Math.max(2, end - start), height: barHeight };
|
|
151
160
|
const bounds = milestone ? { x: start - 8, y: y - 8, width: 16, height: 16 } : { ...geometry };
|
|
152
161
|
const datum = taskDatum(row, analytics, recordId);
|
|
153
162
|
scene.add({
|
|
@@ -182,8 +191,8 @@ function tasksScene(scene, spec, rows, state) {
|
|
|
182
191
|
const from = positions.get(fromId), to = positions.get(toId);
|
|
183
192
|
if (!from || !to) return;
|
|
184
193
|
const critical = highlighted.has(fromId) && highlighted.has(toId) && state.schedule?.criticalEdges?.some(edge => edge.from === fromId && edge.to === toId);
|
|
185
|
-
const bend = from.end + 12;
|
|
186
|
-
arrow(scene, `dependency-${index}-${dependencyIndex}`, [{ x: from.end, y: from.y }, { x: bend, y: from.y }, { x: bend, y: to.y -
|
|
194
|
+
const bend = from.end + 12, routeOffset = Math.max(6, barOffset + 3);
|
|
195
|
+
arrow(scene, `dependency-${index}-${dependencyIndex}`, [{ x: from.end, y: from.y }, { x: bend, y: from.y }, { x: bend, y: to.y - routeOffset }, { x: to.start - 10, y: to.y - routeOffset }, { x: to.start - 10, y: to.y }, { x: to.start, y: to.y }], { from: fromId, to: toId, critical }, critical);
|
|
187
196
|
}));
|
|
188
197
|
state.timeDomain = [min, max];
|
|
189
198
|
}
|
|
@@ -242,7 +251,8 @@ function diagramScene(scene, spec, rows, state) {
|
|
|
242
251
|
if (mode === 'architecture' && layers.length) layers.forEach((layer, index) => {
|
|
243
252
|
const layerHeight = plot.height / layers.length;
|
|
244
253
|
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
|
-
|
|
254
|
+
const compactLegendSize = Math.min(10, Number(spec.theme.typography.legend.size) || 9);
|
|
255
|
+
text(scene, `layer-label-${layer.id}`, layer.label || layer.id, plot.x - 12, plot.y + index * layerHeight + Math.min(20, layerHeight / 2 + 4), { textAnchor: 'end', font: state.compact ? `550 ${compactLegendSize}px system-ui, sans-serif` : spec.theme.typography.legend.font });
|
|
246
256
|
});
|
|
247
257
|
rows.forEach((row, index) => {
|
|
248
258
|
const rank = ranks.get(row.id), lane = Math.max(0, lanes.findIndex(item => item.id === row.laneId));
|
|
@@ -252,10 +262,13 @@ function diagramScene(scene, spec, rows, state) {
|
|
|
252
262
|
const generated = layout[row.id];
|
|
253
263
|
const layerIndex = mode === 'architecture' && layers.length ? Math.max(0, layers.findIndex(layer => layer.id === row.layerId)) : -1;
|
|
254
264
|
const layerHeight = layers.length ? plot.height / layers.length : plot.height;
|
|
265
|
+
const nodeWidth = mode === 'architecture' && layers.length ? Math.min(112, Math.max(72, plot.width / layers.length - 4)) : 112;
|
|
266
|
+
const nodeHeight = mode === 'architecture' && layers.length ? Math.min(36, Math.max(14, layerHeight - 6)) : 36;
|
|
267
|
+
const nodeFont = mode === 'architecture' && state.compact ? '550 10px system-ui, sans-serif' : null;
|
|
255
268
|
const layerSlot = layerIndex >= 0 ? layerSlots.get(row.layerId) : 0;
|
|
256
269
|
if (layerIndex >= 0) layerSlots.set(row.layerId, layerSlot + 1);
|
|
257
270
|
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 -
|
|
271
|
+
const geometry = { x: row.position?.x ?? (layerIndex >= 0 ? plot.x + (layerIndex + 0.5) * plot.width / layers.length - nodeWidth / 2 : generated?.x ?? plot.x + rank * gapX + 12), y: row.position?.y ?? (layerIndex >= 0 ? plot.y + layerIndex * layerHeight + (layerSlot + 0.5) * layerHeight / Math.max(1, layerCount) - nodeHeight / 2 : generated?.y ?? plot.y + (lanes.length ? lane * laneHeight : 0) + (slot + 0.5) * (lanes.length ? laneHeight : Math.max(plot.height, sameCell * 64)) / sameCell - 18), width: row.size?.width || nodeWidth, height: row.size?.height || nodeHeight };
|
|
259
272
|
positions.set(row.id, geometry);
|
|
260
273
|
if (collapsedGroups.has(row.groupId)) return;
|
|
261
274
|
const depth = row.parentId ? (ranks.get(row.id) || 0) : 0;
|
|
@@ -265,7 +278,7 @@ function diagramScene(scene, spec, rows, state) {
|
|
|
265
278
|
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) };
|
|
266
279
|
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 });
|
|
267
280
|
});
|
|
268
|
-
text(scene, `node-label-${row.id}`, row.label || row.id, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 + 5, { fill: markText(spec.theme, spec.colors[lane % spec.colors.length]), textAnchor: 'middle' });
|
|
281
|
+
text(scene, `node-label-${row.id}`, row.label || row.id, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 + (geometry.height < 20 ? 3 : 5), { fill: markText(spec.theme, spec.colors[lane % spec.colors.length]), textAnchor: 'middle', ...(nodeFont ? { font: nodeFont } : {}) });
|
|
269
282
|
});
|
|
270
283
|
const groupBoxes = new Map();
|
|
271
284
|
groups.forEach(group => {
|
|
@@ -300,7 +313,7 @@ function diagramScene(scene, spec, rows, state) {
|
|
|
300
313
|
const fromPortDefinition = fromNode.groupId && collapsedGroups.has(fromNode.groupId) ? null : fromNode?.ports?.find(port => port.id === edge.fromPort);
|
|
301
314
|
const toPortDefinition = toNode.groupId && collapsedGroups.has(toNode.groupId) ? null : toNode?.ports?.find(port => port.id === edge.toPort);
|
|
302
315
|
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);
|
|
316
|
+
const path = routeEdgePath({ ...edge, curveTension: edge.curveTension ?? diagramSpec.diagram.curveTension, grid: diagramSpec.diagram.grid, obstacles, fromPortDefinition, toPortDefinition, preserveSides: mode === 'architecture' }, from, to, routing);
|
|
304
317
|
const points = path.points;
|
|
305
318
|
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);
|
|
306
319
|
if (edge.label) {
|
|
@@ -328,7 +341,7 @@ export function buildProjectScene(spec) {
|
|
|
328
341
|
const data = { ...normalizeData(visibleRows), rows: visibleRows.map(row => ({ ...row })), sourceRows: sourceRows.map(row => ({ ...row })) };
|
|
329
342
|
const scene = new Scene(spec.width, spec.height);
|
|
330
343
|
scene.theme = spec.theme;
|
|
331
|
-
const left = ['gantt', 'timeline', 'milestone', 'swimlane'
|
|
344
|
+
const left = spec.type === 'architecture' ? Math.min(110, spec.width * 0.2) : ['gantt', 'timeline', 'milestone', 'swimlane'].includes(spec.type) ? projectLabelReserve(spec, sourceRows) : spec.padding.left;
|
|
332
345
|
const title = titleLayout(spec);
|
|
333
346
|
const plotTop = Math.max(spec.padding.top, title.bottom + (title.bottom ? 12 : 0)) + (spec.type === 'burndown' ? 16 : 0);
|
|
334
347
|
const plotWidth = Math.max(1, spec.width - left - spec.padding.right), xLabels = axisLabelLayout(spec, Array(Math.max(2, Math.min(5, Math.floor(plotWidth / 100)))).fill('2000-00-00'), plotWidth);
|
package/src/theme.mjs
CHANGED
|
@@ -154,9 +154,12 @@ export function planStyle(spec = {}, options = {}) {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
function channel(value) {
|
|
157
|
-
const text = String(value || '').
|
|
158
|
-
|
|
159
|
-
|
|
157
|
+
const text = String(value || '').trim();
|
|
158
|
+
const hex = text.replace('#', '');
|
|
159
|
+
const rgb = /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)/i.exec(text);
|
|
160
|
+
const values = /^[0-9a-f]{6}$/i.test(hex) ? [0, 2, 4].map(index => parseInt(hex.slice(index, index + 2), 16)) : rgb ? rgb.slice(1, 4).map(Number) : null;
|
|
161
|
+
if (!values || values.some(value => value < 0 || value > 255)) return null;
|
|
162
|
+
return values.map(value => value / 255).map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
function luminance(value) {
|