@taylorwong/ichartjs 2.0.11 → 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 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.11`.
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.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.
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/position` | Multi-series Cartesian, Pie, and Radar |
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.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.
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.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.
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.11`.
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.11/skills/ichartjs \
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/position` | 多系列笛卡尔图、Pie、Radar |
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.11/skills/ichartjs`。WorkBuddy 可通过自身 Skill 界面导入该带 Tag 的目录;只有当前 CLI 明确声明对应适配器时才使用宿主专用 `--agent` 参数。
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
- 节点移动后必须验证边、箭头和标签跟随;同时检查 Canvas 与 SVG 的边命中、手柄拖动、waypoint 持久化、键盘和导出一致性。
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.11`。
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
- 需要覆盖日期错误、循环依赖、Scope Change、Forecast 和编辑历史。
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.11/skills/ichartjs \
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.11",
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"
@@ -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.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.
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 measured = entries.map((entry, index) => ({ ...entry, index, width: Math.min(availableWidth, swatchSize + 6 + String(entry.name).length * size * 0.58 + gap) }));
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') {
@@ -158,6 +177,8 @@ function addAxes(scene, spec, state) {
158
177
  if (spec.grid?.visible !== false) ticks.forEach((value, index) => scene.add(new SceneNode({ id: `grid-x-${index}`, type: 'line', geometry: { x1: numericX(value), y1: plot.y, x2: numericX(value), y2: plot.y + plot.height }, style: { stroke: spec.grid?.color || spec.theme.grid, strokeWidth: spec.theme.marks.gridWidth }, zIndex: -2 })));
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' }));
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' });
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' });
161
182
  return;
162
183
  }
163
184
  const labels = state.temporal || state.quantitativeX ? timeTicks(state.xMin, state.xMax, 5) : categories;
@@ -170,7 +191,8 @@ function addAxes(scene, spec, state) {
170
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' }));
171
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') }));
172
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' });
173
- if (spec.yAxis?.title) addText(scene, 'axis-y-title', spec.yAxis.title, 8, plot.y + 12, { fill: spec.theme.text, font: font(spec, 'axis') });
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 });
174
196
  }
175
197
 
176
198
  function addTitles(scene, spec, positions) {
@@ -352,6 +374,7 @@ export function buildScene(spec) {
352
374
  const series = Array.isArray(spec.encoding.y) ? spec.encoding.y : [{ ...spec.encoding.y, name: spec.encoding.y?.name || spec.encoding.y?.field }];
353
375
  const entries = legendEntries(spec, data, series, colors);
354
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.' });
355
378
  scene._labelArea = state.plot;
356
379
  addTitles(scene, spec, state.chrome.title);
357
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.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 };
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
- return Math.max(size, String(text ?? '').length * size * 0.58);
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.length > 17 ? `${label.slice(0, 16)}…` : label, plot.x - 12, y + 4, { textAnchor: 'end' });
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
- 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);
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
- const layerSlot = layerIndex >= 0 ? layerSlots.get(row.layerId) : 0;
269
- if (layerIndex >= 0) layerSlots.set(row.layerId, layerSlot + 1);
270
- const layerCount = layerIndex >= 0 ? rows.filter(item => item.layerId === row.layerId).length : 1;
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 };
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' ? Math.min(110, spec.width * 0.2) : ['gantt', 'timeline', 'milestone', 'swimlane'].includes(spec.type) ? projectLabelReserve(spec, sourceRows) : spec.padding.left;
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);