@taylorwong/ichartjs 2.0.12 → 2.0.13
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 +10 -0
- package/README.md +2 -2
- package/docs/agent/charting-scenario.md +3 -1
- package/docs/agent/coding-agent-integration.md +1 -1
- package/docs/agent/development/release-sop.md +1 -1
- package/docs/agent/development/roadmap.md +1 -1
- package/docs/agent/diagram-scenario.md +4 -0
- package/docs/agent/frontend-integration.md +1 -1
- package/docs/agent/project-scenario.md +3 -0
- package/docs/agent/usage-scenarios.md +1 -1
- package/docs/agent/zh-CN/charting-scenario.md +3 -1
- package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
- package/docs/agent/zh-CN/diagram-scenario.md +17 -1
- package/docs/agent/zh-CN/frontend-integration.md +1 -1
- package/docs/agent/zh-CN/project-scenario.md +4 -2
- package/docs/agent/zh-CN/usage-scenarios.md +1 -1
- package/package.json +5 -1
- package/skills/ichartjs/SKILL.md +1 -1
- package/src/charts.mjs +40 -19
- package/src/diagram.mjs +30 -0
- package/src/index.mjs +1 -1
- package/src/layout.mjs +26 -1
- package/src/project.mjs +45 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.13 - 2026-09-22
|
|
4
|
+
|
|
5
|
+
- Made legend, axis, and project-label layout Unicode-aware, added collision-safe y-axis titles, and restored right-axis title rendering.
|
|
6
|
+
- Simplified clear forward Gantt dependencies to three segments while preserving typed dependency endpoints and safe fallback routing.
|
|
7
|
+
- Arranged automatic Architecture nodes horizontally within layers, preserved explicit positions, and added Node plus Chrome layout regression coverage.
|
|
8
|
+
|
|
9
|
+
## 2.0.12 - 2026-09-21
|
|
10
|
+
|
|
11
|
+
- Fixed Bar charts so `xAxis.title` and `yAxis.title` render through the dedicated horizontal-bar axis branch.
|
|
12
|
+
|
|
3
13
|
## 2.0.11 - 2026-09-21
|
|
4
14
|
|
|
5
15
|
- Improved promotional chart composition by expanding the internal plotting areas for trend, delivery, and architecture examples without enlarging their cards.
|
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ getCapabilities
|
|
|
44
44
|
npm install @taylorwong/ichartjs@^2
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
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.13`.
|
|
48
48
|
|
|
49
49
|
### Optional Agent Skill
|
|
50
50
|
|
|
@@ -66,7 +66,7 @@ For a non-interactive global Codex installation:
|
|
|
66
66
|
npx skills add wanghetommy/ichartjs --skill ichartjs --agent codex --global --yes
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
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.13/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.
|
|
70
70
|
|
|
71
71
|
### Agent workflow
|
|
72
72
|
|
|
@@ -35,13 +35,15 @@ Agent usage and development guide for generic data analysis and metric visualiza
|
|
|
35
35
|
| Axis title and format | `xAxis.title/format`, `yAxis.title/format` | Line, Area, Bar, Column, Scatter |
|
|
36
36
|
| Readable numeric domain | `yAxis.nice`, `yAxis.ticks`, `yAxis.domain` | Line, Area, Bar, Column, Scatter |
|
|
37
37
|
| Data labels | `labels.enabled/format` | Charts that declare `labels` in capabilities |
|
|
38
|
-
| Legend | `legend.visible
|
|
38
|
+
| Legend | `legend.visible` | Multi-series Cartesian, Pie, and Radar |
|
|
39
39
|
| Gauge domain | `domain: [min, max]` | Gauge |
|
|
40
40
|
| Heatmap color domain | `colorScale.domain` | Heatmap |
|
|
41
41
|
| Radar indicator domain | `indicators[].min/max` | Radar |
|
|
42
42
|
|
|
43
43
|
Do not put `title`, `format`, `labels`, or `legend` under `encoding`; `validateSpec()` reports those placements as warnings. Numeric y-axes use readable domains by default (`nice: true`, `ticks: "auto"`). Use `yAxis.domain: [min, max]` for an explicit range, or `yAxis.nice: false` to retain the raw boundary. `xAxis.min/max` and `xAxis.domain` are unsupported for categorical/time layouts and produce a structured warning.
|
|
44
44
|
|
|
45
|
+
Layout is deterministic and renderer-independent. Legend items use Unicode-aware text estimates for spacing and automatically wrap when a row is full; a single label that cannot fit is truncated and reports `LEGEND_OVERFLOW`. Numeric left and right y-axis titles are centered beside their tick columns, while Bar keeps its categorical y-axis title horizontal above the category-label column. Axis and project-label reserves are computed before the plot rectangle, so Canvas, SVG, headless rendering, and exports share the same geometry.
|
|
46
|
+
|
|
45
47
|
## Encoding Contracts
|
|
46
48
|
|
|
47
49
|
Use only the channels declared for the selected chart: Cartesian charts use `encoding.x` and `encoding.y`; Pie and Funnel use `encoding.category` and `encoding.value`; Gauge uses only `encoding.value`; Heatmap uses `encoding.x`, `encoding.y`, and `encoding.color`; Radar uses `indicators[].field`. `validateSpec()` reports `UNSUPPORTED_ENCODING_CHANNEL` for an unused channel and `MISSING_ENCODING_FIELD` when a referenced field is absent. Gauge additionally requires `domain: [min, max]`; values outside the domain are clamped for the rendered arc and report `VALUE_CLAMPED`. Pie reports `NEGATIVE_VALUE_DROPPED` instead of silently treating negative values as valid shares, and reports `ZERO_TOTAL` for an empty result.
|
|
@@ -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.13/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
|
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
1. `npm run check` → exit 0
|
|
17
17
|
2. `npm test` → exit 0 (all current tests pass)
|
|
18
18
|
3. `npm run docs:check` → exit 0
|
|
19
|
-
4. `npm run agent:check` → exit 0
|
|
19
|
+
4. `npm run agent:check` and `npm run test:browser` → both exit 0 (`test:browser` requires Chrome or `CHROME_BIN`)
|
|
20
20
|
5. `npm whoami` → output = `taylorwong`
|
|
21
21
|
6. `npm config get registry` → output = `https://registry.npmjs.org/`
|
|
22
22
|
|
|
@@ -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.13` includes the completed Iteration 12 structured-diagram work, follow-up chart/menu fixes, hardened Agent chart contracts, Unicode-aware layout, typed Gantt routing, and horizontal Architecture layers. Geographic charts and 3D rendering remain out of scope until explicitly reintroduced.
|
|
4
4
|
|
|
5
5
|
## Current Status
|
|
6
6
|
|
|
@@ -11,6 +11,8 @@ Agent usage and development guide for process modeling, responsibility mapping,
|
|
|
11
11
|
|
|
12
12
|
Architecture and mindmap are both structured diagrams, but they are not interchangeable: architecture describes declared domain or system relationships, while a mindmap describes an idea hierarchy.
|
|
13
13
|
|
|
14
|
+
Architecture layers are horizontal bands ordered from top to bottom. Automatic layout places nodes in the same layer left to right on a shared row, then adds another row only when the available width cannot hold the layer. An explicit `node.position` remains authoritative, including after editing; automatic layout never rewrites the Spec.
|
|
15
|
+
|
|
14
16
|
## Data Model
|
|
15
17
|
|
|
16
18
|
```js
|
|
@@ -71,6 +73,7 @@ Current limitations:
|
|
|
71
73
|
- `deleteGroup` defaults to `ungroup`; use `delete-members` only after explicit host confirmation.
|
|
72
74
|
- Canvas keeps basic accessibility text, while SVG exposes richer diagram semantics.
|
|
73
75
|
- Cross-browser matrix and physical-device validation remain acceptance work, not runtime guarantees.
|
|
76
|
+
- Mixed manual and automatic Architecture positions can overlap; explicit positions are preserved rather than silently moved.
|
|
74
77
|
|
|
75
78
|
## Agent Workflow
|
|
76
79
|
|
|
@@ -107,4 +110,5 @@ Current limitations:
|
|
|
107
110
|
- Copy/paste preserves internal edges and produces deterministic new IDs.
|
|
108
111
|
- Group collapse hides member nodes and keeps group-level state visible.
|
|
109
112
|
- Groups, ports, and invalid references produce structured validation results.
|
|
113
|
+
- Architecture nodes without explicit positions are arranged horizontally within their declared layer; explicit positions survive rendering and editing.
|
|
110
114
|
- Canvas and SVG produce equivalent edge selection, handle dragging, persisted waypoints, keyboard behavior, and exports.
|
|
@@ -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.13`.
|
|
14
14
|
|
|
15
15
|
Use the package through a bundler or another environment that resolves npm ESM imports:
|
|
16
16
|
|
|
@@ -40,12 +40,14 @@ Agent usage and development guide for project planning, delivery tracking, and p
|
|
|
40
40
|
- Gantt `start` and `end` must be valid dates, and `end` cannot precede `start`.
|
|
41
41
|
- `progress` uses the `0–100` percentage convention.
|
|
42
42
|
- `dependencies` use stable task IDs, either as strings or `{ id, type, lag, lead }` objects, and must form an acyclic graph.
|
|
43
|
+
- Dependency objects support `finish-to-start`, `start-to-start`, `finish-to-finish`, and `start-to-finish`. Rendering uses the matching task endpoints; a clear forward finish-to-start relationship uses a compact three-segment route, while overlapping or reverse relationships use a safe outer route.
|
|
43
44
|
- Issue aging requires an explicit ISO `today` reference; missing or invalid dates produce warnings instead of guessed buckets.
|
|
44
45
|
- Calendar-aware scheduling may also use dependency objects with explicit `type`, `lag`, and `lead`.
|
|
45
46
|
- `baselineStart`/`baselineEnd` and `actualStart`/`actualEnd` should be treated as explicit source inputs, not inferred values.
|
|
46
47
|
- Burndown `scopeChange` represents scope movement, not completed work.
|
|
47
48
|
- A forecast is an estimate derived from current samples, not a commitment or fact.
|
|
48
49
|
- Capacity warnings, risk quadrants, and aging buckets should stay explainable from source fields.
|
|
50
|
+
- Gantt, Timeline, and Milestone reserve their left label column from Unicode-aware text widths. Labels wider than the bounded column are truncated by rendered width rather than character count.
|
|
49
51
|
|
|
50
52
|
## Editing
|
|
51
53
|
|
|
@@ -85,6 +87,7 @@ Typical operations:
|
|
|
85
87
|
|
|
86
88
|
- All four project chart types initialize in the Gallery.
|
|
87
89
|
- Gantt dependencies, critical-path results, and variance overlays are explainable.
|
|
90
|
+
- Dependency paths connect the endpoints declared by their dependency type; an unobstructed forward finish-to-start path has at most three segments.
|
|
88
91
|
- Burndown scope changes and forecasts have dedicated tests.
|
|
89
92
|
- Capacity, risk, and aging analytics preserve stable record IDs.
|
|
90
93
|
- Editing commands support preview, commit, undo, and redo.
|
|
@@ -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.13/skills/ichartjs \
|
|
125
125
|
--agent codex --global --yes
|
|
126
126
|
```
|
|
127
127
|
|
|
@@ -35,13 +35,15 @@
|
|
|
35
35
|
| 坐标轴标题和格式 | `xAxis.title/format`、`yAxis.title/format` | Line、Area、Bar、Column、Scatter |
|
|
36
36
|
| 易读数值域 | `yAxis.nice`、`yAxis.ticks`、`yAxis.domain` | Line、Area、Bar、Column、Scatter |
|
|
37
37
|
| 数据标签 | `labels.enabled/format` | 能力清单声明支持 labels 的图表 |
|
|
38
|
-
| 图例 | `legend.visible
|
|
38
|
+
| 图例 | `legend.visible` | 多系列笛卡尔图、Pie、Radar |
|
|
39
39
|
| Gauge 坐标域 | `domain: [min, max]` | Gauge |
|
|
40
40
|
| Heatmap 颜色域 | `colorScale.domain` | Heatmap |
|
|
41
41
|
| Radar 指标域 | `indicators[].min/max` | Radar |
|
|
42
42
|
|
|
43
43
|
不要把 `title`、`format`、`labels` 或 `legend` 放在 `encoding` 中;`validateSpec()` 会报告结构化警告。数值纵轴默认使用易读域(`nice: true`、`ticks: "auto"`);需要固定范围时使用 `yAxis.domain: [min, max]`,需要保留原始边界时使用 `yAxis.nice: false`。分类/时间横轴的 `min/max` 和 `domain` 不支持。
|
|
44
44
|
|
|
45
|
+
布局保持确定性且不依赖 Renderer。图例使用 Unicode-aware 文本宽度估算来计算间距,并在单行空间不足时自动换行;单个标签仍无法放入可用宽度时会省略并报告 `LEGEND_OVERFLOW`。左右数值 Y 轴标题在刻度列外侧居中显示,Bar 的分类 Y 轴标题则保持水平并位于分类标签列上方。坐标轴和项目标签空间会在确定绘图区之前完成预留,因此 Canvas、SVG、Headless 和导出共享相同几何。
|
|
46
|
+
|
|
45
47
|
## Encoding 契约
|
|
46
48
|
|
|
47
49
|
不同图表只接受对应的数据通道:笛卡尔图表使用 `encoding.x`/`encoding.y`;Pie、Funnel 使用 `encoding.category`/`encoding.value`;Gauge 只使用 `encoding.value`;Heatmap 使用 `encoding.x`/`encoding.y`/`encoding.color`;Radar 使用 `indicators[].field`。字段不存在或通道不支持会成为校验错误,不应静默改名。Gauge 必须提供 `domain: [min, max]`;超出范围时弧形会限制在范围内,并产生 `VALUE_CLAMPED`。Pie 遇到负值会报告 `NEGATIVE_VALUE_DROPPED`,没有正数占比时会报告 `ZERO_TOTAL`。全部图表的最小可执行 Spec 见 `@taylorwong/ichartjs/recipes/minimal-specs`;ESM 中用 `with { type: 'json' }` 导入,并从 `catalog.examples[type]` 取模板。
|
|
@@ -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.13/skills/ichartjs`。WorkBuddy 可通过自身 Skill 界面导入该带 Tag 的目录;只有当前 CLI 明确声明对应适配器时才使用宿主专用 `--agent` 参数。
|
|
26
26
|
|
|
27
27
|
使用 `npx skills add wanghetommy/ichartjs --list` 验证发现结果,其中应包含 `ichartjs`。
|
|
28
28
|
|
|
@@ -11,6 +11,21 @@
|
|
|
11
11
|
|
|
12
12
|
架构图和思维导图都属于结构化图,但不能混用:架构图表达明确的领域或系统关系,思维导图表达想法层级。
|
|
13
13
|
|
|
14
|
+
Architecture 的 `layers` 是从上到下排列的水平分层。同层自动布局节点优先保持同一 Y 坐标并从左到右排列;只有可用宽度不足时才增加下一行。显式 `node.position` 始终优先,包括编辑后的坐标;自动布局不会回写 Spec。
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
{
|
|
18
|
+
type: 'architecture',
|
|
19
|
+
layers: [{ id: 'access', label: '接入层' }, { id: 'service', label: '服务层' }],
|
|
20
|
+
nodes: [
|
|
21
|
+
{ id: 'web', label: 'Web 前端', layerId: 'access' },
|
|
22
|
+
{ id: 'gateway', label: 'API 网关', layerId: 'access' },
|
|
23
|
+
{ id: 'order', label: '订单服务', layerId: 'service' }
|
|
24
|
+
],
|
|
25
|
+
edges: [{ from: 'gateway', to: 'order' }]
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
14
29
|
思维导图建议使用简洁的父子数据:
|
|
15
30
|
|
|
16
31
|
```js
|
|
@@ -45,6 +60,7 @@ Mindmap 默认使用曲线父子连线。`diagram.curveTension` 支持 `0.2` 到
|
|
|
45
60
|
- Group 只支持平级 Group,不支持嵌套。
|
|
46
61
|
- Group bounds 由成员几何和 `group.padding` 推导;`resizeGroup` 会缩放成员位置和尺寸,不持久化第二个 Group 矩形。
|
|
47
62
|
- Canvas 提供基础无障碍文本,SVG 提供更丰富的 Diagram 语义。
|
|
63
|
+
- Architecture 混用手工坐标与自动坐标时可能发生重叠;运行时会保留手工坐标,不会静默搬动节点。
|
|
48
64
|
|
|
49
65
|
## Agent 流程
|
|
50
66
|
|
|
@@ -63,4 +79,4 @@ Mindmap 默认使用曲线父子连线。`diagram.curveTension` 支持 `0.2` 到
|
|
|
63
79
|
- 专用 Demo:`playground/diagram-editor.html`
|
|
64
80
|
- 全量 Gallery:`playground/project-gallery.html`
|
|
65
81
|
|
|
66
|
-
|
|
82
|
+
节点移动后必须验证边、箭头和标签跟随;Architecture 无显式坐标的同层节点应横向优先排列;同时检查 Canvas 与 SVG 的边命中、手柄拖动、waypoint 持久化、键盘和导出一致性。
|
|
@@ -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.13`。
|
|
10
10
|
|
|
11
11
|
```js
|
|
12
12
|
import { createChart } from '@taylorwong/ichartjs';
|
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
|
|
16
16
|
- 日期必须有效,Gantt 的 `end` 不得早于 `start`。
|
|
17
17
|
- `progress` 使用 `0–100` 百分比。
|
|
18
|
-
- `dependencies` 使用稳定任务 ID
|
|
18
|
+
- `dependencies` 使用稳定任务 ID,支持字符串或 `{ id, type, lag, lead }` 对象,依赖图不能有环。
|
|
19
|
+
- 依赖类型支持 `finish-to-start`、`start-to-start`、`finish-to-finish` 和 `start-to-finish`;连线使用对应任务端点。具有净空的正向 FS 关系使用三段紧凑路径,重叠或反向关系使用外侧绕行。
|
|
19
20
|
- `scopeChange` 表示范围变化,不等于已完成工作量。
|
|
20
21
|
- Forecast 是估计结果,不能描述为承诺或事实。
|
|
22
|
+
- Gantt、Timeline 和 Milestone 根据 Unicode-aware 文本宽度预留左侧标签列;超过列宽的标签按像素省略,不按字符数截断。
|
|
21
23
|
|
|
22
24
|
## 编辑流程
|
|
23
25
|
|
|
@@ -37,4 +39,4 @@ Schema → Command → Validate → Preview → Confirm → Commit → ChangeSet
|
|
|
37
39
|
- 测试:`tests/core.test.mjs`
|
|
38
40
|
- 验收:`playground/project-gallery.html`
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
需要覆盖日期错误、循环依赖、四种依赖类型端点、FS 三段路径、Scope Change、Forecast、中文标签边界和编辑历史。
|
|
@@ -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.13/skills/ichartjs \
|
|
120
120
|
--agent codex --global --yes
|
|
121
121
|
```
|
|
122
122
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taylorwong/ichartjs",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.13",
|
|
4
4
|
"description": "Agent-first, renderer-independent charting and project visualization runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"test": "node --test tests/*.test.mjs",
|
|
38
|
+
"test:browser": "node --test tests/browser/*.test.mjs",
|
|
38
39
|
"check": "for file in src/*.mjs; do node --check \"$file\" || exit 1; done",
|
|
39
40
|
"docs:check": "node scripts/check-agent-docs.mjs",
|
|
40
41
|
"example:agent": "node examples/agent-workflow.mjs",
|
|
@@ -42,6 +43,9 @@
|
|
|
42
43
|
"agent:check": "npm run docs:check && npm run check && npm test",
|
|
43
44
|
"rc:check": "npm test && npm run check && git diff --check"
|
|
44
45
|
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"playwright-core": "^1.63.0"
|
|
48
|
+
},
|
|
45
49
|
"repository": {
|
|
46
50
|
"type": "git",
|
|
47
51
|
"url": "git+https://github.com/wanghetommy/ichartjs.git"
|
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.13/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
|
|
package/src/charts.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { applyTransforms } from './transforms.mjs';
|
|
|
8
8
|
import { buildProjectScene } from './project.mjs';
|
|
9
9
|
import { formatValue } from './format.mjs';
|
|
10
10
|
import { contrastRatio, resolveTheme } from './theme.mjs';
|
|
11
|
-
import { axisLabelLayout, boxesOverlap, estimateTextWidth, fontSize, titleLayout } from './layout.mjs';
|
|
11
|
+
import { axisLabelLayout, boxesOverlap, estimateTextWidth, fontSize, titleLayout, truncateText } from './layout.mjs';
|
|
12
12
|
|
|
13
13
|
const pick = (row, encoding, fallback) => row?.[encoding?.field || fallback];
|
|
14
14
|
const font = (spec, role, fallback = '12px system-ui') => spec.theme?.typography?.[role]?.font || fallback;
|
|
@@ -72,7 +72,11 @@ function chromeLayout(spec, entries = []) {
|
|
|
72
72
|
const size = fontSize(spec, 'legend', 12), lineHeight = Math.max(18, size * Number(spec.theme?.typography?.legend?.lineHeight || 1.4));
|
|
73
73
|
const swatchSize = Math.max(8, Math.round(size * 0.75)), gap = Math.max(12, Math.round(size));
|
|
74
74
|
const availableWidth = Math.max(1, spec.width - spec.padding.left - spec.padding.right);
|
|
75
|
-
const
|
|
75
|
+
const maxLabelWidth = Math.max(size, availableWidth - swatchSize - 6 - gap);
|
|
76
|
+
const measured = entries.map((entry, index) => {
|
|
77
|
+
const rawName = String(entry.name), name = truncateText(rawName, maxLabelWidth, size);
|
|
78
|
+
return { ...entry, name, rawName, truncated: name !== rawName, index, width: swatchSize + 6 + estimateTextWidth(name, size) + gap };
|
|
79
|
+
});
|
|
76
80
|
const rows = [];
|
|
77
81
|
measured.forEach(item => {
|
|
78
82
|
let row = rows.at(-1);
|
|
@@ -88,7 +92,7 @@ function chromeLayout(spec, entries = []) {
|
|
|
88
92
|
row.items.forEach(item => { items.push({ ...item, x, y, swatchSize }); x += item.width; });
|
|
89
93
|
});
|
|
90
94
|
const bottom = top + rows.length * lineHeight;
|
|
91
|
-
return { title, legend: { visible: true, items, bottom }, bottom: Math.max(title.bottom, bottom) };
|
|
95
|
+
return { title, legend: { visible: true, items, bottom, truncatedCount: measured.filter(item => item.truncated).length }, bottom: Math.max(title.bottom, bottom) };
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
function styledSpec(spec) {
|
|
@@ -107,18 +111,6 @@ function layout(spec, data, legendEntries = []) {
|
|
|
107
111
|
const dates = temporal ? data.rows.map(row => new Date(row[xField]).getTime()).filter(Number.isFinite) : [];
|
|
108
112
|
const xNumbers = quantitativeX ? data.rows.map(row => Number(row[xField])).filter(Number.isFinite) : [];
|
|
109
113
|
const xMin = dates.length ? Math.min(...dates) : xNumbers.length ? Math.min(...xNumbers) : 0, xMax = dates.length ? Math.max(...dates) : xNumbers.length ? Math.max(...xNumbers) : 1;
|
|
110
|
-
const categoryField = spec.encoding.x?.field || 'name';
|
|
111
|
-
const categoryLabelLength = spec.type === 'bar' ? Math.max(0, ...data.rows.map(row => String(row[categoryField] ?? '').length)) : 0;
|
|
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
|
-
const sideLabelLength = Math.max(categoryLabelLength, matrixLabelLength);
|
|
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);
|
|
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;
|
|
119
|
-
const xLabels = axisLabelLayout(spec, spec.type === 'bar' ? [] : axisLabelTexts, plotWidth);
|
|
120
|
-
const bottomReserve = Math.max(p.bottom, Math.ceil(16 + xLabels.projectedHeight + (spec.xAxis?.title ? 20 : 0)));
|
|
121
|
-
const plot = { x: plotLeft, y: plotTop, width: plotWidth, height: Math.max(1, height - plotTop - bottomReserve) };
|
|
122
114
|
const yEncodings = Array.isArray(spec.encoding.y) ? spec.encoding.y : [spec.encoding.y || { field: 'value', type: 'quantitative' }];
|
|
123
115
|
const yField = yEncodings[0]?.field || 'value';
|
|
124
116
|
const stackMode = typeof spec.stack === 'string' ? spec.stack : spec.stack?.mode;
|
|
@@ -140,17 +132,44 @@ function layout(spec, data, legendEntries = []) {
|
|
|
140
132
|
const rawRightMin = Math.min(0, ...(yRightNumbers.length ? yRightNumbers : [0])), rawRightMax = Math.max(1, ...(yRightNumbers.length ? yRightNumbers : [1]));
|
|
141
133
|
const rightType = spec.yAxis?.right?.type || yEncodings[1]?.type || 'linear';
|
|
142
134
|
const [rightMin, rightMax] = resolveAxisDomain(rawRightMin, rawRightMax, spec.yAxis?.right || {}, rightType);
|
|
135
|
+
const yTicks = axisTicks(min, max, spec.yAxis || {}, axisType), rightTicks = axisTicks(rightMin, rightMax, spec.yAxis?.right || {}, rightType);
|
|
136
|
+
const axisSize = fontSize(spec, 'axis', 12);
|
|
137
|
+
const maxWidth = values => Math.max(0, ...values.map(value => estimateTextWidth(value, axisSize)));
|
|
138
|
+
const categoryField = spec.encoding.x?.field || 'name';
|
|
139
|
+
const categoryTexts = data.rows.map(row => String(row[categoryField] ?? ''));
|
|
140
|
+
const matrixYField = spec.encoding.y?.field || 'y', matrixTexts = spec.type === 'heatmap' ? data.rows.map(row => String(row[matrixYField] ?? '')) : [];
|
|
141
|
+
const leftTexts = spec.type === 'bar' ? categoryTexts : yTicks.map(value => formatValue(value, spec.yAxis?.format, spec.locale));
|
|
142
|
+
const leftLabelWidth = maxWidth(leftTexts), rightLabelWidth = yEncodings.length > 1 && !stackMode ? maxWidth(rightTicks.map(value => formatValue(value, spec.yAxis?.right?.format, spec.locale))) : 0;
|
|
143
|
+
const centeredPlot = ['pie', 'funnel', 'gauge', 'radar'].includes(spec.type);
|
|
144
|
+
const sideLabelWidth = Math.max(spec.type === 'bar' ? leftLabelWidth : 0, spec.type === 'heatmap' ? maxWidth(matrixTexts) : 0);
|
|
145
|
+
const leftTitleBand = spec.yAxis?.title && spec.type !== 'bar' ? axisSize + 16 : 0;
|
|
146
|
+
const rightTitleBand = spec.yAxis?.right?.title && yEncodings.length > 1 && !stackMode ? axisSize + 16 : 0;
|
|
147
|
+
const cartesianLeft = 8 + leftTitleBand + leftLabelWidth + 10;
|
|
148
|
+
const plotLeft = centeredPlot
|
|
149
|
+
? Math.max(p.left, p.right)
|
|
150
|
+
: ['bar', 'heatmap'].includes(spec.type)
|
|
151
|
+
? Math.min(width * 0.35, Math.max(p.left, sideLabelWidth + 24))
|
|
152
|
+
: Math.max(p.left, cartesianLeft);
|
|
153
|
+
const plotRight = centeredPlot ? plotLeft : Math.max(p.right, rightLabelWidth ? rightLabelWidth + rightTitleBand + 18 : 0);
|
|
154
|
+
const labelTopReserve = spec.type === 'column' && spec.labels?.enabled ? Math.ceil(fontSize(spec, 'label', 12) * 1.5 + 4) : 0;
|
|
155
|
+
const barTitleReserve = spec.type === 'bar' && spec.yAxis?.title ? axisSize + 8 : 0;
|
|
156
|
+
const plotTop = Math.max(p.top, chrome.bottom + (chrome.bottom ? 12 : 0)) + labelTopReserve + barTitleReserve;
|
|
157
|
+
const plotWidth = Math.max(1, width - plotLeft - plotRight);
|
|
158
|
+
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;
|
|
159
|
+
const xLabels = axisLabelLayout(spec, spec.type === 'bar' ? [] : axisLabelTexts, plotWidth);
|
|
160
|
+
const bottomReserve = Math.max(p.bottom, Math.ceil(16 + xLabels.projectedHeight + (spec.xAxis?.title ? 20 : 0)));
|
|
161
|
+
const plot = { x: plotLeft, y: plotTop, width: plotWidth, height: Math.max(1, height - plotTop - bottomReserve) };
|
|
143
162
|
const rightTransform = value => rightType === 'log' ? Math.log10(Math.max(0.000001, Number(value))) : Number(value);
|
|
144
163
|
const yRight = value => plot.y + plot.height - ((rightTransform(value) - rightTransform(rightMin)) / (rightTransform(rightMax) - rightTransform(rightMin) || 1)) * plot.height;
|
|
145
|
-
const yTicks = axisTicks(min, max, spec.yAxis || {}, axisType), rightTicks = axisTicks(rightMin, rightMax, spec.yAxis?.right || {}, rightType);
|
|
146
164
|
const axisInfo = (rawDomain, domain, ticks, axis) => ({ rawDomain, domain, ticks, step: ticks.length > 1 ? (ticks[1] - ticks[0]) : null, policy: validDomain(axis?.domain) ? 'explicit' : axis?.nice === false ? 'raw' : 'nice' });
|
|
147
|
-
return { plot, chrome, xLabels, compact: width < 360 || height < 240, recommendedSize: { minWidth: 280, minHeight: 220 }, xField, xEncoding, temporal, quantitativeX, xMin, xMax, yField, yEncodings, categories, rawMin, rawMax, min, max, rawRightMin, rawRightMax, rightMin, rightMax, yTicks, rightTicks, axes: { y: axisInfo([rawMin, rawMax], [min, max], yTicks, spec.yAxis || {}), right: axisInfo([rawRightMin, rawRightMax], [rightMin, rightMax], rightTicks, spec.yAxis?.right || {}) }, x, y, yRight, axisType, rightType };
|
|
165
|
+
return { plot, chrome, xLabels, axisLayout: { size: axisSize, leftLabelWidth, rightLabelWidth }, compact: width < 360 || height < 240, recommendedSize: { minWidth: 280, minHeight: 220 }, xField, xEncoding, temporal, quantitativeX, xMin, xMax, yField, yEncodings, categories, rawMin, rawMax, min, max, rawRightMin, rawRightMax, rightMin, rightMax, yTicks, rightTicks, axes: { y: axisInfo([rawMin, rawMax], [min, max], yTicks, spec.yAxis || {}), right: axisInfo([rawRightMin, rawRightMax], [rightMin, rightMax], rightTicks, spec.yAxis?.right || {}) }, x, y, yRight, axisType, rightType };
|
|
148
166
|
}
|
|
149
167
|
|
|
150
168
|
function addText(scene, id, text, x, y, style = {}, dataRef = null) { scene.add(new SceneNode({ id, type: 'text', geometry: { text: String(text), x, y }, style, dataRef })); }
|
|
151
169
|
|
|
152
170
|
function addAxes(scene, spec, state) {
|
|
153
171
|
const { plot, categories, min, max, x, y, yRight } = state;
|
|
172
|
+
const axisSize = state.axisLayout.size;
|
|
154
173
|
scene.add(new SceneNode({ id: 'axis-x', type: 'line', geometry: { x1: plot.x, y1: plot.y + plot.height, x2: plot.x + plot.width, y2: plot.y + plot.height }, style: { stroke: spec.theme.axis } }));
|
|
155
174
|
scene.add(new SceneNode({ id: 'axis-y', type: 'line', geometry: { x1: plot.x, y1: plot.y, x2: plot.x, y2: plot.y + plot.height }, style: { stroke: spec.theme.axis } }));
|
|
156
175
|
if (spec.type === 'bar') {
|
|
@@ -159,7 +178,7 @@ function addAxes(scene, spec, state) {
|
|
|
159
178
|
ticks.forEach(value => addText(scene, `label-x-value-${value}`, formatValue(value, spec.xAxis?.format || spec.yAxis?.format, spec.locale), numericX(value), plot.y + plot.height + 22, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'middle' }));
|
|
160
179
|
categories.forEach((category, index) => addText(scene, `label-y-category-${index}`, category, plot.x - 10, plot.y + (index + .5) * plot.height / Math.max(1, categories.length) + 4, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'end' }));
|
|
161
180
|
if (spec.xAxis?.title) addText(scene, 'axis-x-title', spec.xAxis.title, plot.x + plot.width / 2, plot.y + plot.height + 42, { fill: spec.theme.text, font: font(spec, 'axis'), textAnchor: 'middle' });
|
|
162
|
-
if (spec.yAxis?.title) addText(scene, 'axis-y-title', spec.yAxis.title,
|
|
181
|
+
if (spec.yAxis?.title) addText(scene, 'axis-y-title', truncateText(spec.yAxis.title, Math.max(axisSize, plot.x - 16), axisSize), 8, plot.y - 8, { fill: spec.theme.text, font: font(spec, 'axis'), textAnchor: 'start' });
|
|
163
182
|
return;
|
|
164
183
|
}
|
|
165
184
|
const labels = state.temporal || state.quantitativeX ? timeTicks(state.xMin, state.xMax, 5) : categories;
|
|
@@ -172,7 +191,8 @@ function addAxes(scene, spec, state) {
|
|
|
172
191
|
yTicks.forEach(value => addText(scene, `label-y-${value}`, formatValue(value, spec.yAxis?.format, spec.locale), plot.x - 10, y(value) + 4, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'end' }));
|
|
173
192
|
if (state.yEncodings.length > 1 && !spec.stack) state.rightTicks.forEach(value => addText(scene, `label-y-right-${value}`, formatValue(value, spec.yAxis?.right?.format, spec.locale), plot.x + plot.width + 10, yRight(value) + 4, { fill: spec.theme.muted, font: font(spec, 'axis') }));
|
|
174
193
|
if (spec.xAxis?.title) addText(scene, 'axis-x-title', spec.xAxis.title, plot.x + plot.width / 2, plot.y + plot.height + (state.xLabels.rotation ? state.xLabels.projectedHeight + 20 : 42), { fill: spec.theme.text, font: font(spec, 'axis'), textAnchor: 'middle' });
|
|
175
|
-
if (spec.yAxis?.title) addText(scene, 'axis-y-title', spec.yAxis.title, 8, plot.y +
|
|
194
|
+
if (spec.yAxis?.title) addText(scene, 'axis-y-title', truncateText(spec.yAxis.title, plot.height, axisSize), 8 + axisSize / 2, plot.y + plot.height / 2, { fill: spec.theme.text, font: font(spec, 'axis'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle', rotation: -90 });
|
|
195
|
+
if (state.yEncodings.length > 1 && !spec.stack && spec.yAxis?.right?.title) addText(scene, 'axis-y-right-title', truncateText(spec.yAxis.right.title, plot.height, axisSize), spec.width - 8 - axisSize / 2, plot.y + plot.height / 2, { fill: spec.theme.text, font: font(spec, 'axis'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle', rotation: 90 });
|
|
176
196
|
}
|
|
177
197
|
|
|
178
198
|
function addTitles(scene, spec, positions) {
|
|
@@ -354,6 +374,7 @@ export function buildScene(spec) {
|
|
|
354
374
|
const series = Array.isArray(spec.encoding.y) ? spec.encoding.y : [{ ...spec.encoding.y, name: spec.encoding.y?.name || spec.encoding.y?.field }];
|
|
355
375
|
const entries = legendEntries(spec, data, series, colors);
|
|
356
376
|
const state = layout(spec, data, entries);
|
|
377
|
+
if (state.chrome.legend.truncatedCount) data.warnings.push({ code: 'LEGEND_OVERFLOW', path: 'legend', count: state.chrome.legend.truncatedCount, message: `${state.chrome.legend.truncatedCount} legend label${state.chrome.legend.truncatedCount === 1 ? '' : 's'} exceeded the available width and was truncated.`, suggestion: 'Use shorter category names or a wider chart.' });
|
|
357
378
|
scene._labelArea = state.plot;
|
|
358
379
|
addTitles(scene, spec, state.chrome.title);
|
|
359
380
|
if (!['pie', 'funnel', 'gauge', 'heatmap', 'radar'].includes(spec.type)) addAxes(scene, spec, state);
|
package/src/diagram.mjs
CHANGED
|
@@ -95,9 +95,39 @@ export function diagramGraph(spec) {
|
|
|
95
95
|
return { spec: normalized, nodes, edges, incoming, outgoing };
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
export function layoutArchitectureNodes(spec, bounds = { x: 0, y: 0, width: 640, height: 360 }) {
|
|
99
|
+
const normalized = normalizeDiagramSpec(spec), layers = normalized.layers, nodes = normalized.nodes;
|
|
100
|
+
if (!layers.length) return {};
|
|
101
|
+
const boxes = {}, grid = Number(normalized.diagram.grid) || 0, snap = value => grid > 0 ? Math.round(value / grid) * grid : value;
|
|
102
|
+
const layerHeight = bounds.height / layers.length;
|
|
103
|
+
layers.forEach((layer, layerIndex) => {
|
|
104
|
+
const members = nodes.filter(node => node.layerId === layer.id);
|
|
105
|
+
if (!members.length) return;
|
|
106
|
+
const widest = Math.max(72, ...members.map(node => Number(node.size?.width) || 112));
|
|
107
|
+
const columns = Math.max(1, Math.min(members.length, Math.floor((bounds.width + 12) / (widest + 12))));
|
|
108
|
+
const rows = Math.ceil(members.length / columns);
|
|
109
|
+
const slotWidth = bounds.width / columns, slotHeight = layerHeight / rows;
|
|
110
|
+
members.forEach((node, index) => {
|
|
111
|
+
const row = Math.floor(index / columns), column = index % columns;
|
|
112
|
+
const count = row === rows - 1 ? members.length - row * columns : columns;
|
|
113
|
+
const rowOffset = (bounds.width - count * slotWidth) / 2;
|
|
114
|
+
const width = Number(node.size?.width) || Math.min(112, Math.max(72, slotWidth - 12));
|
|
115
|
+
const height = Number(node.size?.height) || Math.min(36, Math.max(14, slotHeight - 6));
|
|
116
|
+
boxes[node.id] = {
|
|
117
|
+
x: snap(node.position?.x ?? bounds.x + rowOffset + column * slotWidth + (slotWidth - width) / 2),
|
|
118
|
+
y: snap(node.position?.y ?? bounds.y + layerIndex * layerHeight + row * slotHeight + (slotHeight - height) / 2),
|
|
119
|
+
width,
|
|
120
|
+
height
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
return boxes;
|
|
125
|
+
}
|
|
126
|
+
|
|
98
127
|
export function layoutDiagram(spec, bounds = { x: 0, y: 0, width: 640, height: 360 }) {
|
|
99
128
|
const graph = diagramGraph(spec), mode = graph.spec.diagram.layout, positions = new Map(), gapX = Math.max(150, bounds.width / Math.max(1, graph.nodes.length)), gapY = 72;
|
|
100
129
|
if (mode === 'manual') graph.nodes.forEach(node => positions.set(node.id, { x: node.position?.x ?? bounds.x, y: node.position?.y ?? bounds.y }));
|
|
130
|
+
else if (graph.spec.diagram.mode === 'architecture' && graph.spec.layers.length) Object.entries(layoutArchitectureNodes(graph.spec, bounds)).forEach(([id, box]) => positions.set(id, { x: box.x, y: box.y }));
|
|
101
131
|
else if (graph.spec.diagram.mode === 'mindmap') {
|
|
102
132
|
const roots = graph.nodes.filter(node => !graph.incoming.get(node.id).length), children = new Map(graph.nodes.map(node => [node.id, []]));
|
|
103
133
|
graph.edges.forEach(edge => { if (children.has(edge.from)) children.get(edge.from).push(edge.to); });
|
package/src/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.13', 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/layout.mjs
CHANGED
|
@@ -19,8 +19,33 @@ export function titleLayout(spec) {
|
|
|
19
19
|
return { titleY, subtitleY, bottom };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function characterWidth(character) {
|
|
23
|
+
if (/[\p{Mark}\u200d\ufe0e\ufe0f]/u.test(character)) return 0;
|
|
24
|
+
if (/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Extended_Pictographic}]/u.test(character)) return 1;
|
|
25
|
+
if (/\s/u.test(character)) return 0.33;
|
|
26
|
+
if (/[ilI1|.,:;'`!]/u.test(character)) return 0.32;
|
|
27
|
+
if (/[MW@#%&]/u.test(character)) return 0.82;
|
|
28
|
+
return 0.58;
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
export function estimateTextWidth(text, size = 12) {
|
|
23
|
-
|
|
32
|
+
const width = Array.from(String(text ?? '')).reduce((sum, character) => sum + characterWidth(character), 0) * size;
|
|
33
|
+
return Math.max(size, width);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function truncateText(text, maxWidth, size = 12) {
|
|
37
|
+
const value = String(text ?? '');
|
|
38
|
+
if (!(maxWidth > 0) || estimateTextWidth(value, size) <= maxWidth) return value;
|
|
39
|
+
const suffix = '…', suffixWidth = estimateTextWidth(suffix, size);
|
|
40
|
+
if (suffixWidth > maxWidth) return '';
|
|
41
|
+
const characters = Array.from(value);
|
|
42
|
+
let low = 0, high = characters.length;
|
|
43
|
+
while (low < high) {
|
|
44
|
+
const middle = Math.ceil((low + high) / 2);
|
|
45
|
+
if (estimateTextWidth(`${characters.slice(0, middle).join('')}${suffix}`, size) <= maxWidth) low = middle;
|
|
46
|
+
else high = middle - 1;
|
|
47
|
+
}
|
|
48
|
+
return `${characters.slice(0, low).join('')}${suffix}`;
|
|
24
49
|
}
|
|
25
50
|
|
|
26
51
|
export function axisLabelLayout(spec, labels = [], availableWidth = 0) {
|
package/src/project.mjs
CHANGED
|
@@ -4,19 +4,48 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { Scene, cubicBezierPoint } from './scene.mjs';
|
|
6
6
|
import { normalizeData } from './data.mjs';
|
|
7
|
-
import { layoutDiagram, normalizeDiagramData, routeEdgePath } from './diagram.mjs';
|
|
7
|
+
import { layoutArchitectureNodes, layoutDiagram, normalizeDiagramData, routeEdgePath } from './diagram.mjs';
|
|
8
8
|
import { analyzeBurndownSeries, analyzeSchedule } from './project-analytics.mjs';
|
|
9
9
|
import { createLinkedProjectState, filterProjectRows, linkedRecordId } from './project-linking.mjs';
|
|
10
10
|
import { contrastRatio, resolveTheme } from './theme.mjs';
|
|
11
|
-
import { axisLabelLayout, estimateTextWidth, fontSize, titleLayout } from './layout.mjs';
|
|
11
|
+
import { axisLabelLayout, estimateTextWidth, fontSize, titleLayout, truncateText } from './layout.mjs';
|
|
12
12
|
|
|
13
13
|
export const projectTypes = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'];
|
|
14
14
|
const day = 86400000;
|
|
15
15
|
export const timestamp = value => value == null || value === '' ? NaN : new Date(value).getTime();
|
|
16
16
|
const dateLabel = value => new Date(value).toISOString().slice(0, 10);
|
|
17
17
|
const dependencyId = dependency => typeof dependency === 'string' ? dependency : dependency?.id || null;
|
|
18
|
+
const dependencyType = dependency => typeof dependency === 'object' && dependency?.type ? dependency.type : 'finish-to-start';
|
|
18
19
|
const markText = (theme, background) => (contrastRatio(theme.text, background) || 0) >= (contrastRatio(theme.background, background) || 0) ? theme.text : theme.background;
|
|
19
20
|
|
|
21
|
+
function compactRoute(points) {
|
|
22
|
+
return points.filter((point, index) => {
|
|
23
|
+
const previous = points[index - 1], next = points[index + 1];
|
|
24
|
+
if (previous && point.x === previous.x && point.y === previous.y) return false;
|
|
25
|
+
return !(previous && next && (previous.x === point.x && point.x === next.x || previous.y === point.y && point.y === next.y));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function routeGanttDependency(dependency, from, to, positions, barOffset, plot) {
|
|
30
|
+
const type = dependencyType(dependency);
|
|
31
|
+
const sourceAtStart = type === 'start-to-start' || type === 'start-to-finish';
|
|
32
|
+
const targetAtEnd = type === 'finish-to-finish' || type === 'start-to-finish';
|
|
33
|
+
const source = { x: sourceAtStart ? from.start : from.end, y: from.y };
|
|
34
|
+
const target = { x: targetAtEnd ? to.end : to.start, y: to.y };
|
|
35
|
+
const gap = target.x - source.x;
|
|
36
|
+
if (type === 'finish-to-start' && gap > 4) {
|
|
37
|
+
const channelX = source.x + Math.min(12, gap / 2);
|
|
38
|
+
const top = Math.min(source.y, target.y), bottom = Math.max(source.y, target.y);
|
|
39
|
+
const blocked = [...positions.values()].some(position => position !== from && position !== to && position.y + barOffset >= top && position.y - barOffset <= bottom && channelX >= position.start - 2 && channelX <= position.end + 2);
|
|
40
|
+
if (!blocked) return compactRoute([source, { x: channelX, y: source.y }, { x: channelX, y: target.y }, target]);
|
|
41
|
+
}
|
|
42
|
+
const clampX = value => Math.max(plot.x, Math.min(plot.x + plot.width, value));
|
|
43
|
+
const sourceStubX = clampX(source.x + (sourceAtStart ? -12 : 12));
|
|
44
|
+
const targetStubX = clampX(target.x + (targetAtEnd ? 10 : -10));
|
|
45
|
+
const corridorY = Math.min(source.y, target.y) - Math.max(6, barOffset + 3);
|
|
46
|
+
return compactRoute([source, { x: sourceStubX, y: source.y }, { x: sourceStubX, y: corridorY }, { x: targetStubX, y: corridorY }, { x: targetStubX, y: target.y }, target]);
|
|
47
|
+
}
|
|
48
|
+
|
|
20
49
|
function text(scene, id, content, x, y, style = {}) {
|
|
21
50
|
scene.add({ id, type: 'text', geometry: { text: String(content), x, y }, style: { fill: scene.theme?.text || '#334155', font: scene.theme?.typography?.label?.font || '12px system-ui', ...style }, zIndex: 4 });
|
|
22
51
|
}
|
|
@@ -54,6 +83,12 @@ function projectLabelReserve(spec, rows) {
|
|
|
54
83
|
return Math.min(170, Math.max(spec.padding.left, Math.ceil(widest + 20)));
|
|
55
84
|
}
|
|
56
85
|
|
|
86
|
+
function architectureLabelReserve(spec) {
|
|
87
|
+
const labels = (spec.layers || spec.data?.layers || []).map(layer => layer.label || layer.id).filter(Boolean);
|
|
88
|
+
const widest = Math.max(0, ...labels.map(label => estimateTextWidth(label, fontSize(spec, 'legend', 12))));
|
|
89
|
+
return Math.min(170, Math.max(Math.min(110, spec.width * 0.2), Math.ceil(widest + 20)));
|
|
90
|
+
}
|
|
91
|
+
|
|
57
92
|
function projectOverlays(spec) {
|
|
58
93
|
const project = projectConfig(spec);
|
|
59
94
|
return {
|
|
@@ -175,7 +210,7 @@ function tasksScene(scene, spec, rows, state) {
|
|
|
175
210
|
});
|
|
176
211
|
positions.set(row.id || recordId, { start, end, y });
|
|
177
212
|
const label = row.name || row.title || row.label || row.id || `Item ${index + 1}`;
|
|
178
|
-
text(scene, `project-label-${index}`, label.
|
|
213
|
+
text(scene, `project-label-${index}`, truncateText(label, Math.max(12, plot.x - 20), fontSize(spec, 'axis', 12)), plot.x - 12, y + 4, { textAnchor: 'end' });
|
|
179
214
|
if (row.progress != null && !milestone) scene.add({ id: `project-progress-${index}`, type: 'rect', geometry: { ...geometry, width: geometry.width * (row.progress > 1 ? row.progress / 100 : row.progress) }, style: { fill: spec.theme.text, opacity: 0.25 }, zIndex: 3 });
|
|
180
215
|
if (overlays.slack && analytics?.latestFinish && !milestone) {
|
|
181
216
|
const latestEnd = map(analytics.latestFinish);
|
|
@@ -191,8 +226,7 @@ function tasksScene(scene, spec, rows, state) {
|
|
|
191
226
|
const from = positions.get(fromId), to = positions.get(toId);
|
|
192
227
|
if (!from || !to) return;
|
|
193
228
|
const critical = highlighted.has(fromId) && highlighted.has(toId) && state.schedule?.criticalEdges?.some(edge => edge.from === fromId && edge.to === toId);
|
|
194
|
-
|
|
195
|
-
arrow(scene, `dependency-${index}-${dependencyIndex}`, [{ x: from.end, y: from.y }, { x: bend, y: from.y }, { x: bend, y: to.y - routeOffset }, { x: to.start - 10, y: to.y - routeOffset }, { x: to.start - 10, y: to.y }, { x: to.start, y: to.y }], { from: fromId, to: toId, critical }, critical);
|
|
229
|
+
arrow(scene, `dependency-${index}-${dependencyIndex}`, routeGanttDependency(dependency, from, to, positions, barOffset, plot), { from: fromId, to: toId, type: dependencyType(dependency), critical }, critical);
|
|
196
230
|
}));
|
|
197
231
|
state.timeDomain = [min, max];
|
|
198
232
|
}
|
|
@@ -228,6 +262,7 @@ function burndownScene(scene, spec, rows, state) {
|
|
|
228
262
|
function diagramScene(scene, spec, rows, state) {
|
|
229
263
|
const diagramSpec = normalizeDiagramData(spec);
|
|
230
264
|
const plot = state.plot, mode = diagramSpec.diagram.mode, edges = diagramSpec.edges, lanes = diagramSpec.lanes, layers = diagramSpec.layers, groups = diagramSpec.groups, layout = layoutDiagram(diagramSpec, plot);
|
|
265
|
+
const architectureLayout = mode === 'architecture' && layers.length ? layoutArchitectureNodes(diagramSpec, plot) : {};
|
|
231
266
|
rows = diagramSpec.nodes;
|
|
232
267
|
const collapsedGroups = new Set(groups.filter(group => group.collapsed).map(group => group.id));
|
|
233
268
|
const ranks = new Map(rows.map(row => [row.id, 0]));
|
|
@@ -246,8 +281,6 @@ function diagramScene(scene, spec, rows, state) {
|
|
|
246
281
|
scene.add({ id: `lane-${index}`, type: 'rect', geometry: { x: plot.x, y: plot.y + index * laneHeight, width: Math.max(plot.width, columns * gapX), height: laneHeight }, style: { fill: index % 2 ? spec.theme.surface : spec.theme.background, stroke: spec.theme.border }, zIndex: -1 });
|
|
247
282
|
text(scene, `lane-label-${index}`, lane.label || lane.id, plot.x - 12, plot.y + index * laneHeight + 20, { textAnchor: 'end' });
|
|
248
283
|
});
|
|
249
|
-
const layerSlots = new Map();
|
|
250
|
-
if (mode === 'architecture' && layers.length) layers.forEach(layer => layerSlots.set(layer.id, 0));
|
|
251
284
|
if (mode === 'architecture' && layers.length) layers.forEach((layer, index) => {
|
|
252
285
|
const layerHeight = plot.height / layers.length;
|
|
253
286
|
scene.add({ id: `layer-${layer.id}`, type: 'rect', geometry: { x: plot.x, y: plot.y + index * layerHeight, width: plot.width, height: layerHeight }, style: { fill: index % 2 ? spec.theme.surface : spec.theme.background, stroke: spec.theme.border }, zIndex: -2 });
|
|
@@ -260,15 +293,11 @@ function diagramScene(scene, spec, rows, state) {
|
|
|
260
293
|
slots.set(slotKey, slot + 1);
|
|
261
294
|
const sameCell = rows.filter(item => ranks.get(item.id) === rank && (!lanes.length || item.laneId === row.laneId)).length;
|
|
262
295
|
const generated = layout[row.id];
|
|
263
|
-
const layerIndex = mode === 'architecture' && layers.length ? Math.max(0, layers.findIndex(layer => layer.id === row.layerId)) : -1;
|
|
264
|
-
const layerHeight = layers.length ? plot.height / layers.length : plot.height;
|
|
265
|
-
const nodeWidth = mode === 'architecture' && layers.length ? Math.min(112, Math.max(72, plot.width / layers.length - 4)) : 112;
|
|
266
|
-
const nodeHeight = mode === 'architecture' && layers.length ? Math.min(36, Math.max(14, layerHeight - 6)) : 36;
|
|
267
296
|
const nodeFont = mode === 'architecture' && state.compact ? '550 10px system-ui, sans-serif' : null;
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
297
|
+
const architectureBox = architectureLayout[row.id];
|
|
298
|
+
const geometry = architectureBox
|
|
299
|
+
? { ...architectureBox, x: row.position?.x ?? architectureBox.x, y: row.position?.y ?? architectureBox.y }
|
|
300
|
+
: { x: row.position?.x ?? generated?.x ?? plot.x + rank * gapX + 12, y: row.position?.y ?? generated?.y ?? plot.y + (lanes.length ? lane * laneHeight : 0) + (slot + 0.5) * (lanes.length ? laneHeight : Math.max(plot.height, sameCell * 64)) / sameCell - 18, width: row.size?.width || 112, height: row.size?.height || 36 };
|
|
272
301
|
positions.set(row.id, geometry);
|
|
273
302
|
if (collapsedGroups.has(row.groupId)) return;
|
|
274
303
|
const depth = row.parentId ? (ranks.get(row.id) || 0) : 0;
|
|
@@ -341,7 +370,7 @@ export function buildProjectScene(spec) {
|
|
|
341
370
|
const data = { ...normalizeData(visibleRows), rows: visibleRows.map(row => ({ ...row })), sourceRows: sourceRows.map(row => ({ ...row })) };
|
|
342
371
|
const scene = new Scene(spec.width, spec.height);
|
|
343
372
|
scene.theme = spec.theme;
|
|
344
|
-
const left = spec.type === 'architecture' ?
|
|
373
|
+
const left = spec.type === 'architecture' ? architectureLabelReserve(spec) : ['gantt', 'timeline', 'milestone', 'swimlane'].includes(spec.type) ? projectLabelReserve(spec, sourceRows) : spec.padding.left;
|
|
345
374
|
const title = titleLayout(spec);
|
|
346
375
|
const plotTop = Math.max(spec.padding.top, title.bottom + (title.bottom ? 12 : 0)) + (spec.type === 'burndown' ? 16 : 0);
|
|
347
376
|
const plotWidth = Math.max(1, spec.width - left - spec.padding.right), xLabels = axisLabelLayout(spec, Array(Math.max(2, Math.min(5, Math.floor(plotWidth / 100)))).fill('2000-00-00'), plotWidth);
|