@taylorwong/ichartjs 2.0.8 → 2.0.10

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.10 - 2026-09-21
4
+
5
+ - Fixed Architecture orthogonal edges so arrows preserve facing sides, use visible bends, and avoid overlapping arrow tips.
6
+ - Made project label reserves follow the rendered label font size and synchronized System thinking and Delivery planning typography.
7
+ - Refreshed the GitHub promotional Playground page and animated GIF, including cache-busted preview loading.
8
+
9
+ ## 2.0.9 - 2026-09-20
10
+
11
+ - Fixed Radar normalization so normalized Specs validate and render without unsupported Cartesian encodings.
12
+ - Added explicit Pie diagnostics for dropped negative values and empty totals, with deduplicated runtime health reporting.
13
+ - Made Gauge a true single-value contract, removed default-option false positives for non-Cartesian charts, and clarified top-level Diagram structure errors.
14
+ - Updated minimal recipes and bilingual Agent/Skill guidance for chart-specific channels and ESM JSON recipe imports.
15
+
3
16
  ## 2.0.8 - 2026-09-20
4
17
 
5
18
  - Hardened chart-specific encoding and field validation, including explicit Swimlane requirements and actionable diagnostics for Agents.
package/README.md CHANGED
@@ -4,6 +4,10 @@ iChart.js is an Agent-first, renderer-independent visualization runtime. An Agen
4
4
 
5
5
  Use it in one of three ways: integrate the Runtime into a web project, let a Coding Agent modify a project, or install the official Skill so an Agent can orchestrate chart selection and delivery. The Runtime produces interactive pages, SVG/PNG/JPEG artifacts, JSON checkpoints, and integration code; the Skill does not replace the Runtime.
6
6
 
7
+ <p align="center">
8
+ <img src="docs/assets/github-promo.gif" alt="iChart.js Agent-first chart workflow" width="1280">
9
+ </p>
10
+
7
11
  ## For AI Agents
8
12
 
9
13
  Do not guess chart types or configuration fields from source code. Use the public planning contract:
@@ -40,7 +44,7 @@ getCapabilities
40
44
  npm install @taylorwong/ichartjs@^2
41
45
  ```
42
46
 
43
- As a fallback for environments without npm access, install directly from GitHub: `npm install github:wanghetommy/ichartjs#v2.0.8`.
47
+ As a fallback for environments without npm access, install directly from GitHub: `npm install github:wanghetommy/ichartjs#v2.0.10`.
44
48
 
45
49
  ### Optional Agent Skill
46
50
 
@@ -62,7 +66,7 @@ For a non-interactive global Codex installation:
62
66
  npx skills add wanghetommy/ichartjs --skill ichartjs --agent codex --global --yes
63
67
  ```
64
68
 
65
- For a release-pinned installation, use `npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.8/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.10/skills/ichartjs --agent codex --global --yes`. WorkBuddy users can import the same tagged `skills/ichartjs` URL through the host's Skill interface; do not assume a `--agent workbuddy` adapter unless the installed CLI declares it. Package consumers can still copy `node_modules/@taylorwong/ichartjs/skills/ichartjs` as a manual fallback. After installation, invoke `$ichartjs` when named Skill invocation is supported, or select `ichartjs` in the host UI.
66
70
 
67
71
  ### Agent workflow
68
72
 
@@ -148,6 +152,7 @@ npm run playground
148
152
  ```
149
153
 
150
154
  - Playground Home: `http://localhost:3000/playground/index.html`
155
+ - GitHub Promo: `http://localhost:3000/playground/github-promo.html`
151
156
  - Agent Workbench: `http://localhost:3000/playground/agent-workbench.html`
152
157
  - Complete Gallery: `http://localhost:3000/playground/project-gallery.html`
153
158
  - Foundational Gallery: `http://localhost:3000/playground/foundational-gallery.html`
@@ -10,7 +10,7 @@
10
10
  "pie": { "type": "pie", "renderer": "svg", "data": [{ "id": "a", "name": "A", "value": 12 }] },
11
11
  "scatter": { "type": "scatter", "renderer": "svg", "data": [{ "id": "a", "x": 1, "y": 12 }], "encoding": { "x": { "field": "x" }, "y": { "field": "y" } } },
12
12
  "funnel": { "type": "funnel", "renderer": "svg", "data": [{ "id": "visit", "name": "Visit", "value": 100 }] },
13
- "gauge": { "type": "gauge", "renderer": "svg", "domain": [0, 100], "data": [{ "id": "completion", "name": "Completion", "value": 72 }] },
13
+ "gauge": { "type": "gauge", "renderer": "svg", "domain": [0, 100], "data": [{ "id": "completion", "value": 72 }] },
14
14
  "heatmap": { "type": "heatmap", "renderer": "svg", "data": [{ "id": "a", "x": "Mon", "y": "AM", "value": 12 }], "encoding": { "x": { "field": "x" }, "y": { "field": "y" }, "color": { "field": "value" } } },
15
15
  "radar": { "type": "radar", "renderer": "svg", "indicators": [{ "name": "Quality", "field": "quality", "min": 0, "max": 100 }, { "name": "Speed", "field": "speed", "min": 0, "max": 100 }, { "name": "Coverage", "field": "coverage", "min": 0, "max": 100 }], "data": [{ "id": "team-a", "quality": 80, "speed": 70, "coverage": 90 }] },
16
16
  "gantt": { "type": "gantt", "renderer": "svg", "data": [{ "id": "task-a", "name": "Task A", "start": "2026-09-01", "end": "2026-09-03" }] },
@@ -44,9 +44,9 @@ Do not put `title`, `format`, `labels`, or `legend` under `encoding`; `validateS
44
44
 
45
45
  ## Encoding Contracts
46
46
 
47
- Use only the channels declared for the selected chart: Cartesian charts use `encoding.x` and `encoding.y`; Pie, Funnel, and Gauge use `encoding.category` and `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`.
47
+ 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.
48
48
 
49
- The complete set of small starting Specs is available at `@taylorwong/ichartjs/recipes/minimal-specs`.
49
+ The complete set of small starting Specs is available at `@taylorwong/ichartjs/recipes/minimal-specs`. Import the JSON catalog with `with { type: 'json' }`, then select `catalog.examples[type]`.
50
50
 
51
51
  ## Intent Vocabulary
52
52
 
@@ -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.8/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.10/skills/ichartjs`. WorkBuddy can import that tagged directory through its Skill interface; only use a host-specific `--agent` value when the installed CLI declares it.
37
37
 
38
38
  Verify discovery with `npx skills add wanghetommy/ichartjs --list`; the result should include `ichartjs`.
39
39
 
@@ -14,6 +14,7 @@ The Playground is not a collection of iteration snapshots. Active pages are orga
14
14
  | --- | --- | --- |
15
15
  | `index.html` | Unified Playground home and acceptance entry point. | Runtime version, available chart count, links grouped by catalog/workbench/lab, server instructions, page readiness status, and exact acceptance URLs. |
16
16
  | `project-gallery.html` | Canonical full catalog and first visual smoke test for all 16 public chart types. Keep this URL stable despite the historical filename. | Search and category filters, every public chart type, SVG/Canvas coverage, theme and viewport controls, enlarged preview, visible initialization status, Spec inspection, diagnostics, and links to specialized pages. |
17
+ | `github-promo.html` | English 16:9 promotional surface for the GitHub README and future GIF capture. | Brand message, real Runtime-rendered charts, Agent workflow sequence, English chart settings, and a stable screenshot composition. |
17
18
  | `foundational-gallery.html` | Focused regression gallery for reusable foundational features introduced in Iteration 7. | Grouped/stacked/percent charts, Donut, Combo, Histogram/Bin, Heatmap, Radar, renderer coverage, source record identity, and expected warnings. |
18
19
 
19
20
  ### Agent and Business Workbenches
@@ -138,6 +139,15 @@ Start the cache-safe preview server with `npm run playground` before opening the
138
139
  - Accessibility Lab: `http://localhost:3000/playground/accessibility-lab.html`
139
140
  - Performance Lab: `http://localhost:3000/playground/performance-lab.html`
140
141
 
142
+ ## GitHub Promo GIF
143
+
144
+ `docs/assets/github-promo.gif` is a release-facing README asset generated from the stable English page `playground/github-promo.html`. Refresh it whenever the page composition, chart fixtures, workflow labels, or theme states change.
145
+
146
+ 1. Start the local server with `npm run playground`.
147
+ 2. Open `http://localhost:3000/playground/github-promo.html` and capture one frame for each `[data-step="0"]` through `[data-step="4"]` state at the same `16:9` viewport.
148
+ 3. Convert the five frames to a looping GIF with `ffmpeg`, using a short hold per frame and a generated palette to keep the README asset compact.
149
+ 4. Replace `docs/assets/github-promo.gif`, inspect the animation, and run `git diff --check` before release.
150
+
141
151
  ## Completion Definition
142
152
 
143
153
  The Playground plan is complete when every active page has one clear owner responsibility, all stable URLs are reachable from the Home page, the full catalog and specialized workflows do not conflict, Agent behavior is independently verifiable, cross-cutting quality has dedicated labs, historical iteration pages are no longer release dependencies, and every Iteration 8 delivery report identifies an exact URL and reproducible acceptance procedure.
@@ -1,6 +1,6 @@
1
1
  # iChart.js 2.0 Roadmap
2
2
 
3
- > Roadmap baseline: 2026-09-14. Current release status: `v2.0.8` includes the completed Iteration 12 structured-diagram work, follow-up chart/menu fixes, and Agent contract hardening. Geographic charts and 3D rendering remain out of scope until explicitly reintroduced.
3
+ > Roadmap baseline: 2026-09-14. Current release status: `v2.0.10` includes the completed Iteration 12 structured-diagram work, follow-up chart/menu fixes, hardened Agent chart contracts, and architecture/promo layout fixes. Geographic charts and 3D rendering remain out of scope until explicitly reintroduced.
4
4
 
5
5
  ## Current Status
6
6
 
@@ -10,7 +10,7 @@ This is the production component path. For one-off files or Agent-led repository
10
10
  npm install @taylorwong/ichartjs@^2
11
11
  ```
12
12
 
13
- For environments without npm registry access, install from GitHub as a fallback: `npm install github:wanghetommy/ichartjs#v2.0.8`.
13
+ For environments without npm registry access, install from GitHub as a fallback: `npm install github:wanghetommy/ichartjs#v2.0.10`.
14
14
 
15
15
  Use the package through a bundler or another environment that resolves npm ESM imports:
16
16
 
@@ -121,7 +121,13 @@ Keep `encoding` for field roles and series semantics. Put presentation and axis
121
121
 
122
122
  Numeric Cartesian charts use a readable y-axis domain by default: `yAxis.nice` is `true`, and `yAxis.ticks` is `"auto"`. For an explicit range, use `yAxis.domain: [min, max]`; for example, `{ domain: [0, 2000], ticks: 5 }` produces a stable five-label scale. Set `yAxis.nice: false` to retain the raw data boundary. `yAxis.format` only changes display formatting. `chart.getState().axes` and `chart.explain().axes` expose `rawDomain`, resolved `domain`, `ticks`, `step`, and `policy` for Agent self-checks. `yAxis.right` accepts the same controls for a secondary numeric axis. `xAxis.min/max` and `xAxis.domain` remain unsupported because categorical/time x-axis ranges are derived from records. The other supported domain controls are chart-specific: `gauge.domain`, `heatmap.colorScale.domain`, and `radar.indicators[].min/max`. Project chart date ranges are derived from their records in the current version.
123
123
 
124
- Chart-specific encoding is strict: Cartesian charts use `x`/`y`, Pie/Funnel/Gauge use `category`/`value`, Heatmap uses `x`/`y`/`color`, and Radar uses `indicators[].field`. Missing or unsupported fields are validation errors, not silent fallbacks. Gauge Specs must declare `domain`; inspect `VALUE_CLAMPED` when a value falls outside it. Use the complete minimal catalog at `@taylorwong/ichartjs/recipes/minimal-specs` when starting a new chart.
124
+ Chart-specific encoding is strict: Cartesian charts use `x`/`y`, Pie/Funnel use `category`/`value`, Gauge uses `value`, Heatmap uses `x`/`y`/`color`, and Radar uses `indicators[].field`. Missing or unsupported fields are validation errors, not silent fallbacks. Gauge Specs must declare `domain`; inspect `VALUE_CLAMPED` when a value falls outside it. Pie reports `NEGATIVE_VALUE_DROPPED` for signed values and `ZERO_TOTAL` for an empty part-to-whole result. Use the complete minimal catalog at `@taylorwong/ichartjs/recipes/minimal-specs` when starting a new chart:
125
+
126
+ ```js
127
+ import catalog from '@taylorwong/ichartjs/recipes/minimal-specs' with { type: 'json' };
128
+
129
+ const spec = structuredClone(catalog.examples.radar);
130
+ ```
125
131
 
126
132
  For Agent self-checks, `chart.getState().health` and `chart.explain().health` expose `ready`, `degraded`, or `empty`, plus warning, suppressed-label, clamped-value, and rendered-mark metrics. `locale` defaults to `en-US`; use `locale: "zh-CN"` for localized number/date output while keeping input dates in ISO-8601 form.
127
133
 
@@ -16,7 +16,7 @@ Iteration 8 adds per-chart profiles through `getChartCapability(type)`. Each pro
16
16
 
17
17
  `planChart(data, { intent, renderer })` returns a versioned planning result with a primary chart, alternatives, confidence, reasons, required fields, suggested encodings, assumptions, warnings, unsupported requests, safe next actions, and the selected capability profile. `intent` must be one exact token from `getCapabilities().intents`; natural-language prose must be mapped before planning. An unknown token returns `UNKNOWN_INTENT` and a fallback plan, so Agents must inspect warnings before accepting `primary`. Planning never invents business meaning, units, dates, or missing fields.
18
18
 
19
- Unknown intent results also include `intentKnown`, `intentSuggestions`, and `fallbackUsed`. A chart Spec is chart-specific: Cartesian channels are `x`/`y`, Pie/Funnel/Gauge channels are `category`/`value`, Heatmap channels are `x`/`y`/`color`, and Radar fields live in `indicators`. `validateSpec()` rejects unsupported channels and missing fields. Gauge requires an explicit `domain` and reports `VALUE_CLAMPED` when the rendered value exceeds it.
19
+ Unknown intent results also include `intentKnown`, `intentSuggestions`, and `fallbackUsed`. A chart Spec is chart-specific: Cartesian channels are `x`/`y`, Pie/Funnel channels are `category`/`value`, Gauge uses `value`, Heatmap channels are `x`/`y`/`color`, and Radar fields live in `indicators`. `validateSpec()` rejects unsupported channels and missing fields. Gauge requires an explicit `domain` and reports `VALUE_CLAMPED` when the rendered value exceeds it. Pie reports `NEGATIVE_VALUE_DROPPED` for negative input values and `ZERO_TOTAL` when no positive share can render.
20
20
 
21
21
  ## Spec Rules
22
22
 
@@ -25,7 +25,7 @@ Unknown intent results also include `intentKnown`, `intentSuggestions`, and `fal
25
25
  - Chart layout and data semantics are renderer-independent.
26
26
  - Keep axis titles/formats on `xAxis`/`yAxis`, labels on `labels`, and legend settings on `legend`; misplaced options return structured warnings.
27
27
  - Numeric y-axes use readable domains by default (`yAxis.nice: true`, `yAxis.ticks: "auto"`). Use `yAxis.domain: [min, max]` for an explicit range, `yAxis.nice: false` to retain the raw boundary, and `yAxis.right` for a secondary numeric axis. `chart.getState().axes` and `chart.explain().axes` expose the raw domain, resolved domain, ticks, step, and policy for Agent verification. `xAxis.min/max` and `xAxis.domain` remain unsupported for categorical/time layouts. Chart-specific domains remain available through `gauge.domain`, `heatmap.colorScale.domain`, and `radar.indicators[].min/max`.
28
- - `flow` and `swimlane` use `nodes/edges/lanes`; `architecture` uses `nodes/edges/layers/boundaries`; `mindmap` uses `nodes` with `parentId` and optional `edges`; generic charts use `data.values`.
28
+ - Diagram structure is always top-level: `flow` and `swimlane` use `nodes/edges/lanes`; `architecture` uses `nodes/edges/layers/boundaries`; `mindmap` uses `nodes` with `parentId` and optional `edges`. Do not put these fields under `data`; generic charts use `data.values`.
29
29
  - `chart.getState().health` and `chart.explain().health` expose `ready`, `degraded`, or `empty`, with renderability and warning, suppressed-label, clamped-value, and rendered-mark metrics. `locale` defaults to `en-US`; set `locale: "zh-CN"` for output formatting and keep input dates as ISO-8601 strings.
30
30
 
31
31
  ## Renderer
@@ -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.8/skills/ichartjs \
124
+ npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.10/skills/ichartjs \
125
125
  --agent codex --global --yes
126
126
  ```
127
127
 
@@ -44,7 +44,7 @@
44
44
 
45
45
  ## Encoding 契约
46
46
 
47
- 不同图表只接受对应的数据通道:笛卡尔图表使用 `encoding.x`/`encoding.y`;Pie、Funnel、Gauge 使用 `encoding.category`/`encoding.value`;Heatmap 使用 `encoding.x`/`encoding.y`/`encoding.color`;Radar 使用 `indicators[].field`。字段不存在或通道不支持会成为校验错误,不应静默改名。Gauge 必须提供 `domain: [min, max]`;超出范围时弧形会限制在范围内,并产生 `VALUE_CLAMPED`。全部图表的最小可执行 Spec 见 `@taylorwong/ichartjs/recipes/minimal-specs`。
47
+ 不同图表只接受对应的数据通道:笛卡尔图表使用 `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]` 取模板。
48
48
 
49
49
  ## 意图注册词
50
50
 
@@ -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.8/skills/ichartjs`。WorkBuddy 可通过自身 Skill 界面导入该带 Tag 的目录;只有当前 CLI 明确声明对应适配器时才使用宿主专用 `--agent` 参数。
25
+ Codex 全局无交互安装可追加 `--agent codex --global --yes`。需要固定发布版本时,安装 `https://github.com/wanghetommy/ichartjs/tree/v2.0.10/skills/ichartjs`。WorkBuddy 可通过自身 Skill 界面导入该带 Tag 的目录;只有当前 CLI 明确声明对应适配器时才使用宿主专用 `--agent` 参数。
26
26
 
27
27
  使用 `npx skills add wanghetommy/ichartjs --list` 验证发现结果,其中应包含 `ichartjs`。
28
28
 
@@ -6,7 +6,7 @@ iChart.js 应作为普通 JavaScript UI 组件运行在浏览器应用中。数
6
6
  npm install @taylorwong/ichartjs@^2
7
7
  ```
8
8
 
9
- 无法访问 npm Registry 的环境请用 GitHub 源作为后备:`npm install github:wanghetommy/ichartjs#v2.0.8`。
9
+ 无法访问 npm Registry 的环境请用 GitHub 源作为后备:`npm install github:wanghetommy/ichartjs#v2.0.10`。
10
10
 
11
11
  ```js
12
12
  import { createChart } from '@taylorwong/ichartjs';
@@ -60,7 +60,13 @@ Agent 与开发者使用同一个 ESM 入口。编码 Agent 的完整方式见 [
60
60
 
61
61
  数值型笛卡尔图表默认使用易读的纵轴域:`yAxis.nice` 默认为 `true`,`yAxis.ticks` 默认为 `"auto"`。需要固定范围时使用 `yAxis.domain: [min, max]`,例如 `{ domain: [0, 2000], ticks: 5 }` 可稳定生成五个标签;使用 `yAxis.nice: false` 可保留原始数据边界。`yAxis.format` 只负责格式化刻度显示,`yAxis.right` 支持同样的配置。Agent 可通过 `chart.getState().axes` 或 `chart.explain().axes` 获取原始域、计算域、刻度、步长和策略进行自检。分类/时间横轴的范围仍由数据记录推导,因此 `xAxis.min/max` 和 `xAxis.domain` 不支持。图表专用域配置仍包括:`gauge.domain`、`heatmap.colorScale.domain`、`radar.indicators[].min/max`;项目图表的日期范围当前由数据记录自动计算。
62
62
 
63
- 图表通道是严格按类型定义的:笛卡尔图表使用 `x`/`y`,Pie/Funnel/Gauge 使用 `category`/`value`,Heatmap 使用 `x`/`y`/`color`,Radar 使用 `indicators[].field`。缺失字段或不支持的通道会校验失败;Gauge 必须声明 `domain`。新建图表可直接使用 `@taylorwong/ichartjs/recipes/minimal-specs` 中的最小目录。
63
+ 图表通道是严格按类型定义的:笛卡尔图表使用 `x`/`y`,Pie/Funnel 使用 `category`/`value`,Gauge 只使用 `value`,Heatmap 使用 `x`/`y`/`color`,Radar 使用 `indicators[].field`。缺失字段或不支持的通道会校验失败;Gauge 必须声明 `domain`。Pie 遇到负数会报告 `NEGATIVE_VALUE_DROPPED`,没有正数占比时会报告 `ZERO_TOTAL`。新建图表可直接使用 `@taylorwong/ichartjs/recipes/minimal-specs` 中的最小目录:
64
+
65
+ ```js
66
+ import catalog from '@taylorwong/ichartjs/recipes/minimal-specs' with { type: 'json' };
67
+
68
+ const spec = structuredClone(catalog.examples.radar);
69
+ ```
64
70
 
65
71
  Agent 自检使用 `chart.getState().health` 和 `chart.explain().health`,其中包含 `ready`、`degraded`、`empty`、警告数、隐藏标签数、限制值数和已渲染标记数。`locale` 默认 `en-US`,需要中文输出时设置 `locale: "zh-CN"`,输入日期仍使用 ISO-8601。
66
72
 
@@ -16,7 +16,7 @@ Iteration 8 通过 `getChartCapability(type)` 提供逐图表能力档案,包
16
16
 
17
17
  `planChart(data, { intent, renderer })` 返回版本化规划结果:主选图表、备选项、置信度、原因、缺失字段、建议编码、假设、警告、不支持请求和安全下一步。规划不会虚构业务含义、单位、日期或缺失字段。
18
18
 
19
- 未知 intent 还会返回 `intentKnown`、`intentSuggestions` 和 `fallbackUsed`。Spec 的通道按图表类型约束:笛卡尔图表是 `x`/`y`,Pie/Funnel/Gauge 是 `category`/`value`,Heatmap 是 `x`/`y`/`color`,Radar 字段位于 `indicators`。`validateSpec()` 会拒绝不支持的通道和缺失字段。Gauge 必须显式声明 `domain`,超出范围时会报告 `VALUE_CLAMPED`。
19
+ 未知 intent 还会返回 `intentKnown`、`intentSuggestions` 和 `fallbackUsed`。Spec 的通道按图表类型约束:笛卡尔图表是 `x`/`y`,Pie/Funnel 是 `category`/`value`,Gauge 只使用 `value`,Heatmap 是 `x`/`y`/`color`,Radar 字段位于 `indicators`。`validateSpec()` 会拒绝不支持的通道和缺失字段。Gauge 必须显式声明 `domain`,超出范围时会报告 `VALUE_CLAMPED`;Pie 的负值和空占比分别报告 `NEGATIVE_VALUE_DROPPED`、`ZERO_TOTAL`。
20
20
 
21
21
  `validateSpec()` 分开返回 `errors`、`warnings` 和 `normalizations`;诊断包含稳定代码、JSON 路径、期望值和修复建议。`chart.explain()` 返回编码、转换、交互、假设、警告、稳定记录血缘和无障碍摘要。
22
22
 
@@ -25,7 +25,7 @@ Iteration 8 通过 `getChartCapability(type)` 提供逐图表能力档案,包
25
25
  - Spec 必须是 JSON-serializable。
26
26
  - 渲染前调用 `validateSpec()`。
27
27
  - 布局和数据语义不依赖 Renderer。
28
- - 通用图表使用 `data.values`;Flow/Swimlane 使用 `nodes/edges/lanes`。
28
+ - Diagram 结构字段必须位于 Spec 顶层:通用图表使用 `data.values`;Flow/Swimlane 使用 `nodes/edges/lanes`,Architecture 使用 `nodes/edges/layers/boundaries`,Mindmap 使用带 `parentId` 的 `nodes` 和可选 `edges`。不要将这些字段放在 `data` 内。
29
29
  - `svg` 适合 DOM 交互和可访问性;`canvas` 适合大量图元和绘制性能。
30
30
  - 数值纵轴默认使用易读域(`yAxis.nice: true`、`yAxis.ticks: "auto"`);使用 `yAxis.domain: [min, max]` 固定范围,或使用 `yAxis.nice: false` 保留原始边界。Agent 可通过 `chart.getState().axes` 或 `chart.explain().axes` 自检原始域、计算域、刻度、步长和策略。分类/时间横轴的 `min/max` 和 `domain` 不支持,范围由数据确定。
31
31
  - `chart.getState().health` 和 `chart.explain().health` 提供 `ready`、`degraded` 或 `empty`,以及可渲染性、问题代码、警告数、隐藏标签数、限制值数和已渲染标记数。`locale` 默认 `en-US`,可设置 `zh-CN` 影响输出格式;输入日期应使用 ISO-8601 字符串。
@@ -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.8/skills/ichartjs \
119
+ npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.10/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.8",
3
+ "version": "2.0.10",
4
4
  "description": "Agent-first, renderer-independent charting and project visualization runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -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.8/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.10/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
 
@@ -40,14 +40,14 @@ Do not install the unscoped npm registry package named `ichartjs`; it is current
40
40
  5. Inspect the complete planning result, including `styleRecommendation`, warnings, and fallback status.
41
41
  6. Stop when `requiredFields` is non-empty; request data or explain a supported alternative.
42
42
  7. Build a JSON-serializable Spec using `suggestedEncodings`, the selected capability, and an applicable recipe.
43
- 8. Use chart-specific channels: Cartesian `x`/`y`, Pie/Funnel/Gauge `category`/`value`, Heatmap `x`/`y`/`color`, and Radar `indicators[].field`. Start from `@taylorwong/ichartjs/recipes/minimal-specs` when a type is unfamiliar.
43
+ 8. Use chart-specific channels: Cartesian `x`/`y`, Pie/Funnel `category`/`value`, Gauge `value`, Heatmap `x`/`y`/`color`, and Radar `indicators[].field`. To load a recipe, use `import catalog from '@taylorwong/ichartjs/recipes/minimal-specs' with { type: 'json' }` and select `catalog.examples[type]`.
44
44
  9. Keep titles/formats under `xAxis`/`yAxis`, labels under `labels`, and legend under `legend`; do not place them inside `encoding`.
45
45
  10. Call `validateSpec()` before rendering. Repair `UNSUPPORTED_ENCODING_CHANNEL`, `MISSING_ENCODING_FIELD`, `MISSING_GAUGE_DOMAIN`, `UNKNOWN_INTENT`, misplaced-option, and unsupported-axis warnings before presenting the chart.
46
46
  11. Call `createChart()` only after validation succeeds. Gauge Specs must declare a meaningful `domain`.
47
- 12. Self-check with `chart.explain()`, `chart.getState()`, `health.renderable`, and JSON export. Treat `VALUE_CLAMPED`, `LABELS_SUPPRESSED`, and `ZERO_TOTAL` as material diagnostics to report.
48
- 12. Provide an exact preview URL or artifact path and report assumptions, warnings, and deferred checks.
49
- 13. Prefer `theme: { mode: 'auto', preset, palette }`; preserve explicit user style choices and use `chart.setTheme()` for live switching.
50
- 14. For post-creation visual changes, call `getPreferenceCapabilities(chartType, { locale })`, validate the patch with `validatePreferences()`, apply it with `chart.setPreferences(..., { source: 'agent' })`, and verify `chart.getState().preferences`.
47
+ 12. Self-check with `chart.explain()`, `chart.getState()`, `health.renderable`, and JSON export. Treat `VALUE_CLAMPED`, `LABELS_SUPPRESSED`, `NEGATIVE_VALUE_DROPPED`, and `ZERO_TOTAL` as material diagnostics to report.
48
+ 13. Provide an exact preview URL or artifact path and report assumptions, warnings, and deferred checks.
49
+ 14. Prefer `theme: { mode: 'auto', preset, palette }`; preserve explicit user style choices and use `chart.setTheme()` for live switching.
50
+ 15. For post-creation visual changes, call `getPreferenceCapabilities(chartType, { locale })`, validate the patch with `validatePreferences()`, apply it with `chart.setPreferences(..., { source: 'agent' })`, and verify `chart.getState().preferences`.
51
51
 
52
52
  Use `@taylorwong/ichartjs` for package imports. Use `examples/agent-workflow.mjs` as the executable baseline when working in the repository.
53
53
 
@@ -37,6 +37,6 @@ Verify that:
37
37
  - validation passed without ignored errors;
38
38
  - explanation lineage preserves stable source IDs;
39
39
  - warnings and assumptions are visible in the response;
40
- - `health.renderable` is true and `health.status` is reported; `VALUE_CLAMPED`, `LABELS_SUPPRESSED`, and `ZERO_TOTAL` are not hidden;
40
+ - `health.renderable` is true and `health.status` is reported; `VALUE_CLAMPED`, `LABELS_SUPPRESSED`, `NEGATIVE_VALUE_DROPPED`, and `ZERO_TOTAL` are not hidden;
41
41
  - the preview uses a maintained URL;
42
42
  - replaced charts are destroyed.
@@ -21,7 +21,7 @@ const definitions = {
21
21
  pie: { family: 'part-to-whole', intents: ['part-to-whole'], required: ['dimension', 'measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'legend', 'labels', 'formatting', 'donut', 'zero-total'], ['axes', 'grid', 'zoom', 'pan']) },
22
22
  scatter: { family: 'cartesian', intents: ['relationship', 'correlation'], required: ['measure', 'measure'], optional: ['dimension'], interactions: ['hover', 'tooltip', 'selection', 'brush', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'grid', 'labels', 'formatting'], ['stacked']) },
23
23
  funnel: { family: 'stage', intents: ['funnel', 'conversion'], required: ['dimension', 'measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting'], ['axes', 'grid', 'zoom', 'pan']) },
24
- gauge: { family: 'indicator', intents: ['progress', 'single-value'], required: ['measure'], optional: ['dimension'], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting', 'domain'], ['axes', 'grid', 'legend', 'zoom', 'pan']) },
24
+ gauge: { family: 'indicator', intents: ['progress', 'single-value'], required: ['measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting', 'domain'], ['axes', 'grid', 'legend', 'zoom', 'pan']) },
25
25
  heatmap: { family: 'matrix', intents: ['matrix', 'correlation-grid'], required: ['dimension', 'dimension', 'measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'axes', 'labels', 'formatting', 'color-scale', 'missing-values'], ['zoom', 'pan']) },
26
26
  radar: { family: 'radial', intents: ['multidimensional', 'profile'], required: ['three-or-more-measures'], optional: ['series'], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'legend', 'labels', 'formatting', 'multi-series', 'indicator-domains', 'mixed-unit-warning'], ['axes', 'grid', 'zoom', 'pan']) },
27
27
  gantt: { family: 'project', intents: ['schedule', 'variance'], required: ['id', 'start', 'end'], optional: ['dependencies', 'progress', 'baseline', 'actual'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard', 'linked-filter', 'linked-selection'], features: status([...commonPresentation, 'labels', 'formatting', 'critical-path', 'slack', 'baseline-actual-variance', 'working-calendar', 'dependencies']) },
package/src/charts.mjs CHANGED
@@ -7,7 +7,7 @@ import { normalizeData } from './data.mjs';
7
7
  import { applyTransforms } from './transforms.mjs';
8
8
  import { buildProjectScene } from './project.mjs';
9
9
  import { formatValue } from './format.mjs';
10
- import { resolveTheme } from './theme.mjs';
10
+ import { contrastRatio, resolveTheme } from './theme.mjs';
11
11
  import { axisLabelLayout, boxesOverlap, estimateTextWidth, fontSize, titleLayout } from './layout.mjs';
12
12
 
13
13
  const pick = (row, encoding, fallback) => row?.[encoding?.field || fallback];
@@ -111,9 +111,10 @@ function layout(spec, data, legendEntries = []) {
111
111
  const categoryLabelLength = spec.type === 'bar' ? Math.max(0, ...data.rows.map(row => String(row[categoryField] ?? '').length)) : 0;
112
112
  const matrixYField = spec.encoding.y?.field || 'y', matrixLabelLength = spec.type === 'heatmap' ? Math.max(0, ...data.rows.map(row => String(row[matrixYField] ?? '').length)) : 0;
113
113
  const sideLabelLength = Math.max(categoryLabelLength, matrixLabelLength);
114
- const plotLeft = ['bar', 'heatmap'].includes(spec.type) ? Math.min(width * 0.35, Math.max(p.left, sideLabelLength * fontSize(spec, 'axis', 12) * 0.62 + 24)) : p.left;
115
- const plotTop = Math.max(p.top, chrome.bottom + (chrome.bottom ? 12 : 0));
116
- const plotWidth = Math.max(1, width - plotLeft - p.right);
114
+ const centeredPlot = ['pie', 'funnel', 'gauge', 'radar'].includes(spec.type), plotLeft = centeredPlot ? Math.max(p.left, p.right) : ['bar', 'heatmap'].includes(spec.type) ? Math.min(width * 0.35, Math.max(p.left, sideLabelLength * fontSize(spec, 'axis', 12) * 0.62 + 24)) : p.left, plotRight = centeredPlot ? plotLeft : p.right;
115
+ const labelTopReserve = spec.type === 'column' && spec.labels?.enabled ? Math.ceil(fontSize(spec, 'label', 12) * 1.5 + 4) : 0;
116
+ const plotTop = Math.max(p.top, chrome.bottom + (chrome.bottom ? 12 : 0)) + labelTopReserve;
117
+ const plotWidth = Math.max(1, width - plotLeft - plotRight);
117
118
  const axisLabelTexts = temporal ? timeTicks(xMin, xMax, 5).map(value => formatTime(value, xMax - xMin, spec.xAxis?.format, spec.locale)) : quantitativeX ? timeTicks(xMin, xMax, 5).map(value => formatValue(value, spec.xAxis?.format, spec.locale)) : categories;
118
119
  const xLabels = axisLabelLayout(spec, spec.type === 'bar' ? [] : axisLabelTexts, plotWidth);
119
120
  const bottomReserve = Math.max(p.bottom, Math.ceil(16 + xLabels.projectedHeight + (spec.xAxis?.title ? 20 : 0)));
@@ -190,12 +191,21 @@ function legendEntries(spec, data, series, colors) {
190
191
  return [];
191
192
  }
192
193
 
193
- function addMarkLabel(scene, spec, id, value, x, y, dataRef, bounds = null, anchor = null, formatOverride = null) {
194
+ function readableLabelColor(spec, background) {
195
+ const preferred = spec.labels?.color || spec.theme.text;
196
+ const candidates = [preferred, spec.theme.text, spec.theme.background, '#ffffff', '#172033'];
197
+ return candidates.find(color => (contrastRatio(color, background) || 0) >= 4.5) || preferred;
198
+ }
199
+
200
+ function addMarkLabel(scene, spec, id, value, x, y, dataRef, bounds = null, anchor = null, formatOverride = null, labelBackground = null) {
194
201
  if (!spec.labels?.enabled) return;
195
202
  const text = formatValue(value, formatOverride || spec.labels.format, spec.locale), size = fontSize(spec, 'label', 12), width = estimateTextWidth(text, size), height = size * 1.25;
196
- const area = bounds || scene._labelArea || { x: 0, y: 0, width: spec.width, height: spec.height };
203
+ const baseArea = bounds || scene._labelArea || { x: 0, y: 0, width: spec.width, height: spec.height };
204
+ const area = spec.type === 'column' && !anchor ? { ...baseArea, y: baseArea.y - height - 4, height: baseArea.height + height + 4 } : baseArea;
205
+ const markBackground = spec.type === 'column' && !anchor && y - height / 2 < area.y ? spec.colors?.[dataRef?.seriesIndex || 0] : null;
206
+ const labelY = markBackground ? y + height + 8 : spec.type === 'column' && !anchor ? y - height / 2 - 4 : y;
197
207
  const safeX = Math.max(area.x + width / 2, Math.min(area.x + area.width - width / 2, x));
198
- const safeY = Math.max(area.y + height / 2, Math.min(area.y + area.height - height / 2, y));
208
+ const safeY = Math.max(area.y + height / 2, Math.min(area.y + area.height - height / 2, labelY));
199
209
  const anchorRadius = Number(anchor?.radius) || 0, anchorGap = Number(anchor?.gap) || 6;
200
210
  const anchorBox = anchor ? { x: anchor.x - anchorRadius - anchorGap, y: anchor.y - anchorRadius - anchorGap, width: anchorRadius * 2 + anchorGap * 2, height: anchorRadius * 2 + anchorGap * 2 } : null;
201
211
  const preferred = anchor ? anchor.y - anchorRadius - anchorGap - height / 2 : safeY;
@@ -205,7 +215,7 @@ function addMarkLabel(scene, spec, id, value, x, y, dataRef, bounds = null, anch
205
215
  const placement = shifts.map(candidateY => Math.max(area.y + height / 2, Math.min(area.y + area.height - height / 2, candidateY))).map(candidateY => ({ x: safeX, y: candidateY, box: { x: safeX - width / 2, y: candidateY - height / 2, width, height } })).find(candidate => !occupied.some(box => boxesOverlap(candidate.box, box, 2)) && (!anchorBox || !boxesOverlap(candidate.box, anchorBox, 0)));
206
216
  if (!placement) return;
207
217
  occupied.push(placement.box);
208
- addText(scene, `${id}-label`, text, placement.x, placement.y, { fill: spec.labels.color || spec.theme.text, font: spec.labels.font || font(spec, 'label'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }, dataRef);
218
+ addText(scene, `${id}-label`, text, placement.x, placement.y, { fill: labelBackground || markBackground ? readableLabelColor(spec, labelBackground || markBackground) : spec.labels.color || spec.theme.text, font: spec.labels.font || font(spec, 'label'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }, dataRef);
209
219
  }
210
220
 
211
221
  function hexRgb(value) { const hex = String(value || '').replace('#', ''); if (!/^[0-9a-f]{6}$/i.test(hex)) return [37, 99, 235]; return [0, 2, 4].map(index => parseInt(hex.slice(index, index + 2), 16)); }
@@ -223,7 +233,7 @@ function addHeatmapScene(scene, spec, data, state, colors) {
223
233
  const yStep = Math.max(1, Math.ceil(fontSize(spec, 'axis', 12) * 1.35 / Math.max(1, height)));
224
234
  yValues.forEach((value, index) => { if (index % yStep !== 0 && index !== yValues.length - 1) return; addText(scene, `heatmap-y-${index}`, value, state.plot.x - 10, state.plot.y + (index + .5) * height, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'end', textBaseline: 'middle', baseline: 'middle' }); });
225
235
  let hiddenLabels = 0, visibleLabels = 0;
226
- data.rows.forEach((row, index) => { const xIndex = xValues.indexOf(String(row[xField])), yIndex = yValues.indexOf(String(row[yField])), value = numericValue(row), ratio = Number.isFinite(value) ? (value - min) / (max - min || 1) : 0, geometry = { x: state.plot.x + xIndex * width + gap / 2, y: state.plot.y + yIndex * height + gap / 2, width: Math.max(1, width - gap), height: Math.max(1, height - gap) }; scene.add(new SceneNode({ id: `heatmap-cell-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: Number.isFinite(value) ? colorMix(low, high, ratio) : missing, stroke: spec.theme.background, strokeWidth: 1 }, dataRef: { dataIndex: index, x: row[xField], y: row[yField], value: Number.isFinite(value) ? value : null, recordId: row.id || `record-${index}` }, interactive: true })); if (spec.labels?.enabled && Number.isFinite(value)) { const label = formatValue(value, spec.labels.format, spec.locale), size = fontSize(spec, 'label', 12); if (geometry.width >= estimateTextWidth(label, size) + 8 && geometry.height >= size * 1.35) { addText(scene, `heatmap-cell-label-${index}`, label, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2, { fill: spec.labels.color || (ratio > 0.58 ? '#ffffff' : spec.theme.text), font: spec.labels.font || font(spec, 'label'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }); visibleLabels += 1; } else hiddenLabels += 1; } });
236
+ data.rows.forEach((row, index) => { const xIndex = xValues.indexOf(String(row[xField])), yIndex = yValues.indexOf(String(row[yField])), value = numericValue(row), ratio = Number.isFinite(value) ? (value - min) / (max - min || 1) : 0, cellFill = Number.isFinite(value) ? colorMix(low, high, ratio) : missing, geometry = { x: state.plot.x + xIndex * width + gap / 2, y: state.plot.y + yIndex * height + gap / 2, width: Math.max(1, width - gap), height: Math.max(1, height - gap) }; scene.add(new SceneNode({ id: `heatmap-cell-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: cellFill, stroke: spec.theme.background, strokeWidth: 1 }, dataRef: { dataIndex: index, x: row[xField], y: row[yField], value: Number.isFinite(value) ? value : null, recordId: row.id || `record-${index}` }, interactive: true })); if (spec.labels?.enabled && Number.isFinite(value)) { const label = formatValue(value, spec.labels.format, spec.locale), size = fontSize(spec, 'label', 12); if (geometry.width >= estimateTextWidth(label, size) + 8 && geometry.height >= size * 1.35) { addText(scene, `heatmap-cell-label-${index}`, label, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2, { fill: readableLabelColor(spec, cellFill), font: spec.labels.font || font(spec, 'label'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }); visibleLabels += 1; } else hiddenLabels += 1; } });
227
237
  state.labelLayout = { ...(state.labelLayout || {}), heatmap: { hidden: hiddenLabels, visible: visibleLabels } };
228
238
  if (hiddenLabels) data.warnings.push({ code: 'LABELS_SUPPRESSED', path: 'labels', count: hiddenLabels, message: `${hiddenLabels} heatmap labels were suppressed because cells are too small.`, suggestion: 'Increase chart width or height, reduce the matrix, or disable labels.' });
229
239
  state.matrix = { xValues, yValues, min, max };
@@ -352,7 +362,7 @@ export function buildScene(spec) {
352
362
  else if (spec.type === 'radar') addRadarScene(scene, spec, data, state, colors);
353
363
  else if (spec.type === 'line' || spec.type === 'area') {
354
364
  const mode = typeof spec.stack === 'string' ? spec.stack : spec.stack?.mode, accumulators = data.rows.map(() => ({ positive: 0, negative: 0 }));
355
- series.forEach((encoding, seriesIndex) => { const yMap = seriesIndex === 1 && !mode ? state.yRight : state.y, values = data.rows.map(row => Number(row[encoding.field])), totals = data.rows.map((row, index) => series.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || Math.abs(values[index]) || 1); const points = [], bases = []; data.rows.forEach((row, index) => { let value = values[index]; if (!Number.isFinite(value)) return; if (mode === 'percent') value /= totals[index]; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = mode ? start + value : value; if (mode) accumulators[index][accumulator] = end; points.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(end), row, index, value }); bases.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(start) }); }); if (spec.type === 'area' && points.length) scene.add(new SceneNode({ id: `area-fill-${seriesIndex}`, type: 'path', geometry: { points: [...bases.slice().reverse(), ...points.map(point => ({ x: point.x, y: point.y }))], closed: true }, style: { fill: `${colors[seriesIndex]}33`, stroke: 'transparent' } })); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points, closed: false }, style: { stroke: colors[seriesIndex], strokeWidth: 2, fill: 'none' } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 5 }, bounds: { x: point.x - 8, y: point.y - 8, width: 16, height: 16 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.y - 8, dataRef, state.plot, { x: point.x, y: point.y, radius: 5 }); }); });
365
+ series.forEach((encoding, seriesIndex) => { const yMap = seriesIndex === 1 && !mode ? state.yRight : state.y, values = data.rows.map(row => Number(row[encoding.field])), totals = data.rows.map((row, index) => series.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || Math.abs(values[index]) || 1); const points = [], bases = []; data.rows.forEach((row, index) => { let value = values[index]; if (!Number.isFinite(value)) return; if (mode === 'percent') value /= totals[index]; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = mode ? start + value : value; if (mode) accumulators[index][accumulator] = end; points.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(end), labelY: mode ? (yMap(start) + yMap(end)) / 2 : yMap(end) - 8, row, index, value }); bases.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(start) }); }); if (spec.type === 'area' && points.length) scene.add(new SceneNode({ id: `area-fill-${seriesIndex}`, type: 'path', geometry: { points: [...bases.slice().reverse(), ...points.map(point => ({ x: point.x, y: point.y }))], closed: true }, style: { fill: `${colors[seriesIndex]}33`, stroke: 'transparent' } })); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points, closed: false }, style: { stroke: colors[seriesIndex], strokeWidth: 2, fill: 'none' } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 5 }, bounds: { x: point.x - 8, y: point.y - 8, width: 16, height: 16 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.labelY, dataRef, state.plot, mode ? null : { x: point.x, y: point.y, radius: 5 }); }); });
356
366
  } else if (spec.type === 'bar' || spec.type === 'column') {
357
367
  const band = state.plot.width / Math.max(1, state.categories.length), bar = band * 0.58;
358
368
  const mode = typeof spec.stack === 'string' ? spec.stack : spec.stack?.mode, accumulators = data.rows.map(() => ({ positive: 0, negative: 0 }));
@@ -360,12 +370,13 @@ export function buildScene(spec) {
360
370
  } else if (spec.type === 'scatter') {
361
371
  data.rows.forEach((row, index) => { const xValue = Number(row[state.xField]), yValue = Number(row[state.yField]); if (!Number.isFinite(xValue) || !Number.isFinite(yValue)) return; const geometry = { cx: state.x(xValue), cy: state.y(yValue), r: 5 }, dataRef = { seriesIndex: 0, dataIndex: index, field: state.yField }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'circle', geometry, bounds: { x: geometry.cx - 8, y: geometry.cy - 8, width: 16, height: 16 }, style: { fill: colors[0] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, yValue, geometry.cx, geometry.cy - 8, dataRef, state.plot, { x: geometry.cx, y: geometry.cy, radius: 5 }); });
362
372
  } else if (spec.type === 'pie') {
363
- const categoryField = spec.encoding.category?.field || 'name', valueField = spec.encoding.value?.field || 'value', total = data.rows.reduce((sum, row) => sum + Math.max(0, Number(row[valueField]) || 0), 0), cx = state.plot.x + state.plot.width / 2, cy = state.plot.y + state.plot.height / 2, radius = Math.min(state.plot.width, state.plot.height) * 0.38, innerR = radius * Number(spec.innerRadius || 0); let angle = -Math.PI / 2;
364
- if (!(total > 0)) { data.warnings.push({ code: 'ZERO_TOTAL', path: `encoding.value.${valueField}`, message: 'Pie requires a positive total.' }); addText(scene, 'pie-zero-total', spec.emptyText || 'No positive values', cx, cy, { fill: spec.theme.muted, font: font(spec, 'subtitle'), textAnchor: 'middle' }); }
365
- else data.rows.forEach((row, index) => { const value = Math.max(0, Number(row[valueField]) || 0), end = angle + value / total * Math.PI * 2, middle = angle + (end - angle) / 2, dataRef = { seriesIndex: 0, dataIndex: index, category: row[categoryField], value }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'arc', geometry: { cx, cy, r: radius, innerR, start: angle, end }, bounds: { x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2 }, style: { fill: colors[index % colors.length], stroke: spec.theme.background, strokeWidth: 1 }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value / total, cx + Math.cos(middle) * radius * .72, cy + Math.sin(middle) * radius * .72, dataRef, state.plot, null, spec.labels?.format || { style: 'percent' }); angle = end; });
373
+ const categoryField = spec.encoding.category?.field || 'name', valueField = spec.encoding.value?.field || 'value', values = data.rows.map(row => Number(row[valueField])), total = values.reduce((sum, value) => sum + (Number.isFinite(value) && value > 0 ? value : 0), 0), negativeCount = values.filter(value => Number.isFinite(value) && value < 0).length, cx = state.plot.x + state.plot.width / 2, cy = state.plot.y + state.plot.height / 2, radius = Math.min(state.plot.width, state.plot.height) * 0.38, innerR = radius * Number(spec.innerRadius || 0); let angle = -Math.PI / 2;
374
+ if (negativeCount) data.warnings.push({ code: 'NEGATIVE_VALUE_DROPPED', path: 'encoding.value', count: negativeCount, message: `Pie ignores ${negativeCount} negative value${negativeCount === 1 ? '' : 's'} when calculating shares.`, suggestion: 'Use non-negative part-to-whole values or choose a Cartesian chart for signed measures.' });
375
+ if (!(total > 0)) { data.warnings.push({ code: 'ZERO_TOTAL', path: 'encoding.value', message: 'Pie requires a positive total.', suggestion: 'Provide at least one positive value or render an explicit empty state.' }); addText(scene, 'pie-zero-total', spec.emptyText || 'No positive values', cx, cy, { fill: spec.theme.muted, font: font(spec, 'subtitle'), textAnchor: 'middle' }); }
376
+ else data.rows.forEach((row, index) => { const rawValue = values[index], value = Number.isFinite(rawValue) ? Math.max(0, rawValue) : 0, end = angle + value / total * Math.PI * 2, middle = angle + (end - angle) / 2, fill = colors[index % colors.length], dataRef = { seriesIndex: 0, dataIndex: index, category: row[categoryField], value, rawValue }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'arc', geometry: { cx, cy, r: radius, innerR, start: angle, end }, bounds: { x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2 }, style: { fill, stroke: spec.theme.background, strokeWidth: 1 }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value / total, cx + Math.cos(middle) * radius * .72, cy + Math.sin(middle) * radius * .72, dataRef, state.plot, null, spec.labels?.format || { style: 'percent' }, fill); angle = end; });
366
377
  } else if (spec.type === 'funnel') {
367
378
  const valueField = spec.encoding.value?.field || 'value'; const maxValue = Math.max(...data.rows.map(row => Number(row[valueField]) || 0), 1); const segmentHeight = state.plot.height / data.rows.length;
368
- data.rows.forEach((row, index) => { const value = Number(row[valueField]) || 0, ratio = Math.max(0.1, value / maxValue); const width = state.plot.width * ratio; const geometry = { x: state.plot.x + (state.plot.width - width) / 2, y: state.plot.y + index * segmentHeight, width, height: Math.max(2, segmentHeight - 3) }, dataRef = { seriesIndex: 0, dataIndex: index, field: valueField }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: colors[index % colors.length] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value, spec.width / 2, geometry.y + geometry.height / 2 + 4, dataRef, state.plot); });
379
+ data.rows.forEach((row, index) => { const value = Number(row[valueField]) || 0, ratio = Math.max(0.1, value / maxValue), fill = colors[index % colors.length]; const width = state.plot.width * ratio; const geometry = { x: state.plot.x + (state.plot.width - width) / 2, y: state.plot.y + index * segmentHeight, width, height: Math.max(2, segmentHeight - 3) }, dataRef = { seriesIndex: 0, dataIndex: index, field: valueField }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 + 4, dataRef, state.plot, null, null, fill); });
369
380
  } else if (spec.type === 'gauge') {
370
381
  const valueField = spec.encoding.value?.field || 'value', domain = Array.isArray(spec.domain) ? spec.domain : [0, 100], rawValue = Number(data.rows[0]?.[valueField]), numericValue = Number.isFinite(rawValue) ? rawValue : 0, value = Math.max(Number(domain[0]), Math.min(Number(domain[1]), numericValue)), ratio = (value - Number(domain[0])) / (Number(domain[1]) - Number(domain[0]) || 1), cx = state.plot.x + state.plot.width / 2, cy = state.plot.y + state.plot.height * 0.62, radius = Math.min(state.plot.width, state.plot.height) * 0.36, start = Math.PI, end = start + Math.PI * ratio;
371
382
  if (!Array.isArray(spec.domain)) data.warnings.push({ code: 'MISSING_GAUGE_DOMAIN', path: 'domain', message: 'Gauge rendered with a compatibility fallback; provide an explicit domain to make the value meaningful.' });
package/src/diagram.mjs CHANGED
@@ -184,7 +184,7 @@ export function routeEdgePath(edge, from, to, mode = 'orthogonal') {
184
184
  if (samples.every((point, index) => index === 0 || obstacles.every(box => !segmentIntersectsBox(samples[index - 1], point, box, padding)))) return { points, curve: 'cubic' };
185
185
  return routeEdgePath({ ...edge, waypoints: [] }, from, to, 'orthogonal');
186
186
  }
187
- const sidePairs = edge.fromPortDefinition || edge.toPortDefinition ? [[startSide, endSide]] : [[startSide, endSide], ...['right', 'left', 'top', 'bottom'].flatMap(fromSide => ['right', 'left', 'top', 'bottom'].map(toSide => [fromSide, toSide]))].filter((pair, index, pairs) => pairs.findIndex(item => item[0] === pair[0] && item[1] === pair[1]) === index);
187
+ const sidePairs = edge.preserveSides || edge.fromPortDefinition || edge.toPortDefinition ? [[startSide, endSide]] : [[startSide, endSide], ...['right', 'left', 'top', 'bottom'].flatMap(fromSide => ['right', 'left', 'top', 'bottom'].map(toSide => [fromSide, toSide]))].filter((pair, index, pairs) => pairs.findIndex(item => item[0] === pair[0] && item[1] === pair[1]) === index);
188
188
  const routeForSides = (fromSide, toSide) => {
189
189
  const fromPort = edge.fromPortDefinition || { side: fromSide }, toPort = edge.toPortDefinition || { side: toSide }, { start, end } = diagramConnectionPoints(from, to, fromPort, toPort), padding = Math.max(12, Number(edge.grid) || 8);
190
190
  const segmentIntersects = (first, second, box) => {
@@ -235,6 +235,19 @@ export function routeEdgePath(edge, from, to, mode = 'orthogonal') {
235
235
  const route = routeForSides(fromSide, toSide);
236
236
  if (route) return { points: route, curve: null };
237
237
  }
238
+ if (edge.preserveSides && mode === 'orthogonal') {
239
+ const horizontal = ['left', 'right'].includes(startSide) && ['left', 'right'].includes(endSide);
240
+ const vertical = ['top', 'bottom'].includes(startSide) && ['top', 'bottom'].includes(endSide);
241
+ const snapFallback = value => Math.round(value / (Number(edge.grid) || 8)) * (Number(edge.grid) || 8);
242
+ if (horizontal) {
243
+ const channel = snapFallback((initial.start.x + initial.end.x) / 2);
244
+ return { points: compact([initial.start, { x: channel, y: initial.start.y }, { x: channel, y: initial.end.y }, initial.end]), curve: null };
245
+ }
246
+ if (vertical) {
247
+ const channel = snapFallback((initial.start.y + initial.end.y) / 2);
248
+ return { points: compact([initial.start, { x: initial.start.x, y: channel }, { x: initial.end.x, y: channel }, initial.end]), curve: null };
249
+ }
250
+ }
238
251
  return { points: [initial.start, initial.end], curve: null };
239
252
  }
240
253
 
package/src/index.mjs CHANGED
@@ -344,13 +344,14 @@ export class Chart {
344
344
  resetPreferences(options = {}) { if (this._preferencesStore) { this._preferencesStore.reset({ ...options, scope: options.scope || 'chart', chartId: this.chartId || 'default' }); return this; } this._localPreferences = normalizePreferences({}); this._preferencesSnapshot = JSON.stringify(this._localPreferences); this._resolveStyle(); this.render(); this.emit('preferenceschange', { chart: this, preferences: this.getPreferences(), source: options.source || 'user', scope: 'chart', persisted: false }); return this; }
345
345
  resize(width = this.spec.width, height = this.spec.height) { this.spec.width = width; this.spec.height = height; this.render(); this.emit('resize', { chart: this, width, height }); return this; }
346
346
  getSpec() { return JSON.parse(JSON.stringify(this.spec)); }
347
- _getDiagnostics() { return [...(this._specDiagnostics?.warnings || []), ...(this.model.data?.warnings || []), ...(this.spec.theme?.warnings || [])]; }
347
+ _getDiagnostics() { return [...new Map([...(this._specDiagnostics?.warnings || []), ...(this.model.data?.warnings || []), ...(this.spec.theme?.warnings || [])].map(item => [`${item.code}:${item.path || ''}`, item])).values()]; }
348
348
  _getHealth(warnings) {
349
349
  const isEmpty = this.model.data.rows.length === 0 && !['flow', 'swimlane', 'architecture', 'mindmap'].includes(this.spec.type) || warnings.some(item => item.code === 'ZERO_TOTAL');
350
350
  const marks = [];
351
351
  this.model.scene?.walk?.(node => { if (node.id !== 'root' && !['text', 'line'].includes(node.type)) marks.push(node); });
352
- const suppressedLabels = warnings.reduce((sum, item) => sum + Number(item.count || 0), 0);
353
- return { version: '1.0', status: isEmpty ? 'empty' : warnings.length ? 'degraded' : 'ready', renderable: !isEmpty, issues: [...new Set(warnings.map(item => item.code))], metrics: { warnings: warnings.length, suppressedLabels, clampedValues: warnings.filter(item => item.code === 'VALUE_CLAMPED').length, renderedMarks: marks.length } };
352
+ const suppressedLabels = warnings.filter(item => item.code === 'LABELS_SUPPRESSED').reduce((sum, item) => sum + Number(item.count || 0), 0);
353
+ const clampedValues = warnings.filter(item => item.code === 'VALUE_CLAMPED').reduce((sum, item) => sum + Number(item.count || 1), 0);
354
+ return { version: '1.0', status: isEmpty ? 'empty' : warnings.length ? 'degraded' : 'ready', renderable: !isEmpty, issues: [...new Set(warnings.map(item => item.code))], metrics: { warnings: warnings.length, suppressedLabels, clampedValues, renderedMarks: marks.length } };
354
355
  }
355
356
  getState() { const brandingSignature = discoverCapabilities().branding.signature, warnings = this._getDiagnostics(); return { renderer: this.renderer.constructor.name, width: this.spec.width, height: this.spec.height, dataCount: this.model.data.rows.length, selected: [...this._selected.values()], revision: this._revision, history: this._history.state(), view: clone(this.spec.view || null), style: clone({ name: this.spec.theme.name, mode: this.spec.theme.mode, resolvedMode: this.spec.theme.resolvedMode, preset: this.spec.theme.preset, palette: this.spec.theme.palette, reasons: this.spec.theme.reasons }), axes: clone(this.model.state?.axes || null), health: this._getHealth(warnings), preferences: this.getPreferences(), branding: { enabled: Boolean(this.spec.branding?.enabled), signature: brandingSignature, text: this.spec.branding?.enabled === true ? brandingSignature : null }, warnings: clone(warnings), assumptions: clone(this.model.data?.assumptions || []), normalizations: clone(this._specDiagnostics?.normalizations || []), collapsedGroups: this.getCollapsedGroupIds(), clipboard: { nodes: this._clipboard?.nodes?.length || 0, edges: this._clipboard?.edges?.length || 0 }, projectAnalytics: clone(this.model.state?.projectAnalytics || null), linked: clone(this.model.state?.linked || null) }; }
356
357
  getProjectAnalytics() { return clone(this.model.state?.projectAnalytics || null); }
@@ -638,4 +639,4 @@ export { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, cr
638
639
 
639
640
  export { contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin };
640
641
  export { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, mountChartSettings, normalizePreferences, validatePreferences };
641
- export const iChart = { version: '2.0.8', createChart, inspectData, normalizeData, binData, applyTransforms, normalizeSpec, validateSpec, 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.10', createChart, inspectData, normalizeData, binData, applyTransforms, data, getCapabilities, getChartCapability, getPreferenceCapabilities, planChart, recommend, explainChart, contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, createPreferencesStore, defaultPreferences, normalizePreferences, mergePreferences, validatePreferences, applyPreferencesToSpec, mountChartSettings, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries, normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };
package/src/project.mjs CHANGED
@@ -8,7 +8,7 @@ import { layoutDiagram, normalizeDiagramData, routeEdgePath } from './diagram.mj
8
8
  import { analyzeBurndownSeries, analyzeSchedule } from './project-analytics.mjs';
9
9
  import { createLinkedProjectState, filterProjectRows, linkedRecordId } from './project-linking.mjs';
10
10
  import { contrastRatio, resolveTheme } from './theme.mjs';
11
- import { axisLabelLayout, titleLayout } from './layout.mjs';
11
+ import { axisLabelLayout, estimateTextWidth, fontSize, titleLayout } from './layout.mjs';
12
12
 
13
13
  export const projectTypes = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'];
14
14
  const day = 86400000;
@@ -45,6 +45,15 @@ function projectConfig(spec) {
45
45
  return spec.project || {};
46
46
  }
47
47
 
48
+ function projectLabelReserve(spec, rows) {
49
+ const labels = spec.type === 'swimlane'
50
+ ? (spec.lanes || []).map(lane => lane.label || lane.id)
51
+ : rows.map(row => row.name || row.title || row.label || row.id);
52
+ const labelSize = fontSize(spec, 'label', 12);
53
+ const widest = Math.max(0, ...labels.filter(Boolean).map(label => estimateTextWidth(label, labelSize)));
54
+ return Math.min(170, Math.max(spec.padding.left, Math.ceil(widest + 20)));
55
+ }
56
+
48
57
  function projectOverlays(spec) {
49
58
  const project = projectConfig(spec);
50
59
  return {
@@ -124,7 +133,7 @@ function tasksScene(scene, spec, rows, state) {
124
133
  const analyticsMap = new Map((state.schedule?.tasks || []).map(task => [task.id, task]));
125
134
  const highlighted = new Set(spec.criticalPath === false || !overlays.criticalPath ? [] : Array.isArray(spec.criticalPath) ? spec.criticalPath : state.schedule?.criticalIds || []);
126
135
  const selected = projectSelection(spec, state);
127
- const rowHeight = Math.max(32, plot.height / Math.max(1, rows.length));
136
+ const rowHeight = plot.height / Math.max(1, rows.length), barHeight = Math.min(20, Math.max(10, rowHeight * 0.62)), barOffset = barHeight / 2;
128
137
  rows.forEach((row, index) => {
129
138
  const analytics = analyticsMap.get(row.id);
130
139
  const recordId = linkedRecordId(row, index);
@@ -140,14 +149,14 @@ function tasksScene(scene, spec, rows, state) {
140
149
  if (overlays.baseline && baselineStart) {
141
150
  const baselineStartX = map(baselineStart), baselineEndX = map(baselineEnd || baselineStart);
142
151
  if (milestone) scene.add({ id: `project-baseline-${index}`, type: 'circle', geometry: { cx: baselineStartX, cy: y, r: 4 }, bounds: { x: baselineStartX - 4, y: y - 4, width: 8, height: 8 }, style: { fill: spec.theme.background, stroke: spec.theme.axis, strokeWidth: 1.5 }, zIndex: 1 });
143
- else scene.add({ id: `project-baseline-${index}`, type: 'rect', geometry: { x: baselineStartX, y: y - 14, width: Math.max(2, baselineEndX - baselineStartX), height: 6 }, style: { fill: spec.theme.border }, zIndex: 1 });
152
+ else scene.add({ id: `project-baseline-${index}`, type: 'rect', geometry: { x: baselineStartX, y: y - barOffset - 4, width: Math.max(2, baselineEndX - baselineStartX), height: 4 }, style: { fill: spec.theme.border }, zIndex: 1 });
144
153
  }
145
154
  if (overlays.actual && actualStart) {
146
155
  const actualStartX = map(actualStart), actualEndX = map(actualEnd || actualStart);
147
156
  if (milestone) scene.add({ id: `project-actual-${index}`, type: 'circle', geometry: { cx: actualStartX, cy: y, r: 3 }, bounds: { x: actualStartX - 3, y: y - 3, width: 6, height: 6 }, style: { fill: spec.theme.text }, zIndex: 3 });
148
- else scene.add({ id: `project-actual-${index}`, type: 'rect', geometry: { x: actualStartX, y: y + 8, width: Math.max(2, actualEndX - actualStartX), height: 4 }, style: { fill: spec.theme.text, opacity: 0.35 }, zIndex: 3 });
157
+ else scene.add({ id: `project-actual-${index}`, type: 'rect', geometry: { x: actualStartX, y: y + barOffset + 2, width: Math.max(2, actualEndX - actualStartX), height: 3 }, style: { fill: spec.theme.text, opacity: 0.35 }, zIndex: 3 });
149
158
  }
150
- const geometry = milestone ? { cx: start, cy: y, r: 7 } : { x: start, y: y - 10, width: Math.max(2, end - start), height: 20 };
159
+ const geometry = milestone ? { cx: start, cy: y, r: Math.min(7, barOffset) } : { x: start, y: y - barOffset, width: Math.max(2, end - start), height: barHeight };
151
160
  const bounds = milestone ? { x: start - 8, y: y - 8, width: 16, height: 16 } : { ...geometry };
152
161
  const datum = taskDatum(row, analytics, recordId);
153
162
  scene.add({
@@ -182,8 +191,8 @@ function tasksScene(scene, spec, rows, state) {
182
191
  const from = positions.get(fromId), to = positions.get(toId);
183
192
  if (!from || !to) return;
184
193
  const critical = highlighted.has(fromId) && highlighted.has(toId) && state.schedule?.criticalEdges?.some(edge => edge.from === fromId && edge.to === toId);
185
- const bend = from.end + 12;
186
- arrow(scene, `dependency-${index}-${dependencyIndex}`, [{ x: from.end, y: from.y }, { x: bend, y: from.y }, { x: bend, y: to.y - 15 }, { x: to.start - 10, y: to.y - 15 }, { x: to.start - 10, y: to.y }, { x: to.start, y: to.y }], { from: fromId, to: toId, critical }, critical);
194
+ const bend = from.end + 12, routeOffset = Math.max(6, barOffset + 3);
195
+ arrow(scene, `dependency-${index}-${dependencyIndex}`, [{ x: from.end, y: from.y }, { x: bend, y: from.y }, { x: bend, y: to.y - routeOffset }, { x: to.start - 10, y: to.y - routeOffset }, { x: to.start - 10, y: to.y }, { x: to.start, y: to.y }], { from: fromId, to: toId, critical }, critical);
187
196
  }));
188
197
  state.timeDomain = [min, max];
189
198
  }
@@ -242,7 +251,8 @@ function diagramScene(scene, spec, rows, state) {
242
251
  if (mode === 'architecture' && layers.length) layers.forEach((layer, index) => {
243
252
  const layerHeight = plot.height / layers.length;
244
253
  scene.add({ id: `layer-${layer.id}`, type: 'rect', geometry: { x: plot.x, y: plot.y + index * layerHeight, width: plot.width, height: layerHeight }, style: { fill: index % 2 ? spec.theme.surface : spec.theme.background, stroke: spec.theme.border }, zIndex: -2 });
245
- text(scene, `layer-label-${layer.id}`, layer.label || layer.id, plot.x - 12, plot.y + index * layerHeight + 20, { textAnchor: 'end', font: spec.theme.typography.legend.font });
254
+ const compactLegendSize = Math.min(10, Number(spec.theme.typography.legend.size) || 9);
255
+ text(scene, `layer-label-${layer.id}`, layer.label || layer.id, plot.x - 12, plot.y + index * layerHeight + Math.min(20, layerHeight / 2 + 4), { textAnchor: 'end', font: state.compact ? `550 ${compactLegendSize}px system-ui, sans-serif` : spec.theme.typography.legend.font });
246
256
  });
247
257
  rows.forEach((row, index) => {
248
258
  const rank = ranks.get(row.id), lane = Math.max(0, lanes.findIndex(item => item.id === row.laneId));
@@ -252,10 +262,13 @@ function diagramScene(scene, spec, rows, state) {
252
262
  const generated = layout[row.id];
253
263
  const layerIndex = mode === 'architecture' && layers.length ? Math.max(0, layers.findIndex(layer => layer.id === row.layerId)) : -1;
254
264
  const layerHeight = layers.length ? plot.height / layers.length : plot.height;
265
+ const nodeWidth = mode === 'architecture' && layers.length ? Math.min(112, Math.max(72, plot.width / layers.length - 4)) : 112;
266
+ const nodeHeight = mode === 'architecture' && layers.length ? Math.min(36, Math.max(14, layerHeight - 6)) : 36;
267
+ const nodeFont = mode === 'architecture' && state.compact ? '550 10px system-ui, sans-serif' : null;
255
268
  const layerSlot = layerIndex >= 0 ? layerSlots.get(row.layerId) : 0;
256
269
  if (layerIndex >= 0) layerSlots.set(row.layerId, layerSlot + 1);
257
270
  const layerCount = layerIndex >= 0 ? rows.filter(item => item.layerId === row.layerId).length : 1;
258
- const geometry = { x: row.position?.x ?? (layerIndex >= 0 ? plot.x + (layerIndex + 0.5) * plot.width / layers.length - 56 : generated?.x ?? plot.x + rank * gapX + 12), y: row.position?.y ?? (layerIndex >= 0 ? plot.y + layerIndex * layerHeight + (layerSlot + 0.5) * layerHeight / Math.max(1, layerCount) - 18 : generated?.y ?? plot.y + (lanes.length ? lane * laneHeight : 0) + (slot + 0.5) * (lanes.length ? laneHeight : Math.max(plot.height, sameCell * 64)) / sameCell - 18), width: row.size?.width || 112, height: row.size?.height || 36 };
271
+ const geometry = { x: row.position?.x ?? (layerIndex >= 0 ? plot.x + (layerIndex + 0.5) * plot.width / layers.length - nodeWidth / 2 : generated?.x ?? plot.x + rank * gapX + 12), y: row.position?.y ?? (layerIndex >= 0 ? plot.y + layerIndex * layerHeight + (layerSlot + 0.5) * layerHeight / Math.max(1, layerCount) - nodeHeight / 2 : generated?.y ?? plot.y + (lanes.length ? lane * laneHeight : 0) + (slot + 0.5) * (lanes.length ? laneHeight : Math.max(plot.height, sameCell * 64)) / sameCell - 18), width: row.size?.width || nodeWidth, height: row.size?.height || nodeHeight };
259
272
  positions.set(row.id, geometry);
260
273
  if (collapsedGroups.has(row.groupId)) return;
261
274
  const depth = row.parentId ? (ranks.get(row.id) || 0) : 0;
@@ -265,7 +278,7 @@ function diagramScene(scene, spec, rows, state) {
265
278
  const point = port.side === 'left' ? { x: geometry.x, y: geometry.y + geometry.height * (port.offset ?? 0.5) } : port.side === 'top' ? { x: geometry.x + geometry.width * (port.offset ?? 0.5), y: geometry.y } : port.side === 'bottom' ? { x: geometry.x + geometry.width * (port.offset ?? 0.5), y: geometry.y + geometry.height } : { x: geometry.x + geometry.width, y: geometry.y + geometry.height * (port.offset ?? 0.5) };
266
279
  scene.add({ id: `port-${row.id}-${port.id}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 6, y: point.y - 6, width: 12, height: 12 }, style: { fill: spec.theme.background, stroke: spec.theme.text, strokeWidth: 1.5 }, dataRef: { nodeId: row.id, portId: port.id, groupId: row.groupId || null }, interactive: true, zIndex: 4 });
267
280
  });
268
- text(scene, `node-label-${row.id}`, row.label || row.id, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 + 5, { fill: markText(spec.theme, spec.colors[lane % spec.colors.length]), textAnchor: 'middle' });
281
+ text(scene, `node-label-${row.id}`, row.label || row.id, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 + (geometry.height < 20 ? 3 : 5), { fill: markText(spec.theme, spec.colors[lane % spec.colors.length]), textAnchor: 'middle', ...(nodeFont ? { font: nodeFont } : {}) });
269
282
  });
270
283
  const groupBoxes = new Map();
271
284
  groups.forEach(group => {
@@ -300,7 +313,7 @@ function diagramScene(scene, spec, rows, state) {
300
313
  const fromPortDefinition = fromNode.groupId && collapsedGroups.has(fromNode.groupId) ? null : fromNode?.ports?.find(port => port.id === edge.fromPort);
301
314
  const toPortDefinition = toNode.groupId && collapsedGroups.has(toNode.groupId) ? null : toNode?.ports?.find(port => port.id === edge.toPort);
302
315
  const routing = edge.routing || diagramSpec.diagram.routing;
303
- const path = routeEdgePath({ ...edge, curveTension: edge.curveTension ?? diagramSpec.diagram.curveTension, grid: diagramSpec.diagram.grid, obstacles, fromPortDefinition, toPortDefinition }, from, to, routing);
316
+ const path = routeEdgePath({ ...edge, curveTension: edge.curveTension ?? diagramSpec.diagram.curveTension, grid: diagramSpec.diagram.grid, obstacles, fromPortDefinition, toPortDefinition, preserveSides: mode === 'architecture' }, from, to, routing);
304
317
  const points = path.points;
305
318
  arrow(scene, `edge-${index}`, points, { from: edge.from, to: edge.to, edgeId: edge.id || `edge-${index}`, status: edge.status, routing, curveTension: edge.curveTension ?? diagramSpec.diagram.curveTension, waypoints: edge.waypoints || [], fromPortDefinition, toPortDefinition, fromGroupId: fromNode.groupId || null, toGroupId: toNode.groupId || null }, edge.critical === true, path.curve);
306
319
  if (edge.label) {
@@ -328,7 +341,7 @@ export function buildProjectScene(spec) {
328
341
  const data = { ...normalizeData(visibleRows), rows: visibleRows.map(row => ({ ...row })), sourceRows: sourceRows.map(row => ({ ...row })) };
329
342
  const scene = new Scene(spec.width, spec.height);
330
343
  scene.theme = spec.theme;
331
- const left = ['gantt', 'timeline', 'milestone', 'swimlane', 'architecture'].includes(spec.type) ? Math.min(170, spec.width * 0.34) : spec.padding.left;
344
+ const left = spec.type === 'architecture' ? Math.min(110, spec.width * 0.2) : ['gantt', 'timeline', 'milestone', 'swimlane'].includes(spec.type) ? projectLabelReserve(spec, sourceRows) : spec.padding.left;
332
345
  const title = titleLayout(spec);
333
346
  const plotTop = Math.max(spec.padding.top, title.bottom + (title.bottom ? 12 : 0)) + (spec.type === 'burndown' ? 16 : 0);
334
347
  const plotWidth = Math.max(1, spec.width - left - spec.padding.right), xLabels = axisLabelLayout(spec, Array(Math.max(2, Math.min(5, Math.floor(plotWidth / 100)))).fill('2000-00-00'), plotWidth);
package/src/spec.mjs CHANGED
@@ -62,11 +62,14 @@ function dependencyErrors(rows) {
62
62
 
63
63
  const encodingChannels = {
64
64
  line: ['x', 'y'], area: ['x', 'y'], bar: ['x', 'y'], column: ['x', 'y'], scatter: ['x', 'y'],
65
- pie: ['category', 'value'], funnel: ['category', 'value'], gauge: ['category', 'value'], heatmap: ['x', 'y', 'color'], radar: [],
65
+ pie: ['category', 'value'], funnel: ['category', 'value'], gauge: ['value'], heatmap: ['x', 'y', 'color'], radar: [],
66
66
  gantt: [], timeline: [], milestone: [], burndown: [], flow: [], swimlane: [], architecture: [], mindmap: []
67
67
  };
68
68
 
69
69
  const presentationEncodingKeys = new Set(['title', 'format', 'labels', 'legend']);
70
+ const diagramTypes = new Set(['flow', 'swimlane', 'architecture', 'mindmap']);
71
+ const diagramFields = ['nodes', 'edges', 'lanes', 'groups', 'layers', 'boundaries'];
72
+ const nonCartesianAnalysisTypes = new Set(['pie', 'funnel', 'gauge', 'heatmap', 'radar']);
70
73
 
71
74
  function finiteDomain(domain) {
72
75
  return Array.isArray(domain) && domain.length === 2 && domain.every(value => Number.isFinite(Number(value))) && Number(domain[1]) > Number(domain[0]);
@@ -90,7 +93,7 @@ function validateEncodingContract(input, spec, errors) {
90
93
  else if (!fields.has(field)) errors.push({ code: 'MISSING_ENCODING_FIELD', path: itemPath, message: `Field ${field} is not present in data.values.`, expected: [...fields], suggestion: 'Inspect the data schema and use an existing field.' });
91
94
  });
92
95
  };
93
- if (['pie', 'funnel', 'gauge'].includes(spec.type) && (rawEncoding.x !== undefined || rawEncoding.y !== undefined)) return;
96
+ if (['pie', 'funnel'].includes(spec.type) && (rawEncoding.x !== undefined || rawEncoding.y !== undefined)) return;
94
97
  (encodingChannels[spec.type] || []).forEach(channel => check(`encoding.${channel}`, spec.encoding?.[channel]));
95
98
  if (spec.type === 'radar') (spec.indicators || []).forEach((indicator, index) => check(`indicators.${index}`, indicator));
96
99
  }
@@ -105,18 +108,22 @@ export function normalizeSpec(input = {}) {
105
108
  if (spec.branding.enabled === undefined) spec.branding = { ...spec.branding, enabled: true };
106
109
  }
107
110
  if (!spec.data) spec.data = { values: [] };
108
- if (['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type) && spec.nodes && !spec.data.values) spec.data.values = [];
111
+ if (diagramTypes.has(spec.type) && !Array.isArray(spec.data.values)) spec.data.values = [];
109
112
  if (Array.isArray(spec.data)) spec.data = { values: spec.data };
110
113
  if (!spec.encoding) spec.encoding = {};
111
- if (['pie', 'funnel', 'gauge'].includes(spec.type) && !spec.encoding.category) spec.encoding.category = { field: 'name', type: 'category' };
114
+ if (['pie', 'funnel'].includes(spec.type) && !spec.encoding.category) spec.encoding.category = { field: 'name', type: 'category' };
112
115
  if (['pie', 'funnel', 'gauge'].includes(spec.type) && !spec.encoding.value) spec.encoding.value = { field: 'value', type: 'quantitative' };
113
116
  if (spec.type === 'scatter' && !input.encoding?.x) spec.encoding.x = { field: 'x', type: 'quantitative' };
114
117
  if (spec.type === 'scatter' && !input.encoding?.y) spec.encoding.y = { field: 'y', type: 'quantitative' };
115
118
  if (spec.type === 'heatmap') { spec.encoding.x ||= { field: 'x', type: 'category' }; spec.encoding.y ||= { field: 'y', type: 'category' }; spec.encoding.color ||= { field: 'value', type: 'quantitative' }; }
116
- if (!['pie', 'funnel', 'gauge', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type)) {
119
+ if (!['pie', 'funnel', 'gauge', 'radar', 'gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type)) {
117
120
  spec.encoding.x = spec.encoding.x || { field: 'name', type: 'category' };
118
121
  spec.encoding.y = spec.encoding.y || { field: 'value', type: 'quantitative' };
119
122
  }
123
+ if (nonCartesianAnalysisTypes.has(spec.type)) {
124
+ ['grid', 'xAxis', 'yAxis'].forEach(key => { if (input[key] === undefined) delete spec[key]; });
125
+ ['legend', 'labels'].forEach(key => { if (input[key] === undefined && chartProfiles[spec.type]?.features?.[key] !== 'supported') delete spec[key]; });
126
+ }
120
127
  return spec;
121
128
  }
122
129
 
@@ -172,10 +179,12 @@ export function validateSpec(input = {}) {
172
179
  else if (!finiteDomain(spec.domain)) errors.push({ code: 'INVALID_GAUGE_DOMAIN', path: 'domain', message: 'Gauge domain must be [min, max] with finite numbers and max greater than min.', suggestion: 'Use for example domain: [0, 100].' });
173
180
  }
174
181
  if (spec.type === 'radar' && (!Array.isArray(spec.indicators) || spec.indicators.length < 3)) errors.push({ code: 'INVALID_INDICATORS', path: 'indicators', message: 'Radar requires at least three indicators.', suggestion: 'Declare indicator name, field, min, and max.' });
175
- if (!spec.data || !Array.isArray(spec.data.values)) errors.push({ code: 'INVALID_DATA', path: 'data.values', message: 'data.values must be an array.', suggestion: 'Pass an array of row objects.' });
176
- if (['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type) && !Array.isArray(spec.nodes) && !Array.isArray(spec.data?.nodes)) errors.push({ code: 'INVALID_NODES', path: 'nodes', message: `${spec.type} charts require nodes.`, suggestion: 'Pass nodes on the Spec or in data.nodes.' });
177
- if (['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type)) {
178
- const diagramInput = { type: spec.type, ...(Array.isArray(spec.nodes) ? { nodes: spec.nodes } : {}), ...(Array.isArray(spec.data?.nodes) ? { nodes: spec.data.nodes } : {}), ...(Array.isArray(spec.edges) ? { edges: spec.edges } : {}), ...(Array.isArray(spec.data?.edges) ? { edges: spec.data.edges } : {}), ...(Array.isArray(spec.lanes) ? { lanes: spec.lanes } : {}), ...(Array.isArray(spec.groups) ? { groups: spec.groups } : {}), ...(Array.isArray(spec.layers) ? { layers: spec.layers } : {}), ...(Array.isArray(spec.boundaries) ? { boundaries: spec.boundaries } : {}), ...(spec.diagram ? { diagram: spec.diagram } : {}) };
182
+ if (!diagramTypes.has(spec.type) && (!spec.data || !Array.isArray(spec.data.values))) errors.push({ code: 'INVALID_DATA', path: 'data.values', message: 'data.values must be an array.', suggestion: 'Pass an array of row objects.' });
183
+ const misplacedDiagramFields = diagramTypes.has(spec.type) ? diagramFields.filter(field => input.data && input.data[field] !== undefined) : [];
184
+ misplacedDiagramFields.forEach(field => errors.push({ code: 'MISPLACED_DIAGRAM_FIELD', path: `data.${field}`, message: `${field} belongs at the top level of a ${spec.type} Spec.`, suggestion: `Move data.${field} to ${field}.` }));
185
+ if (diagramTypes.has(spec.type) && !Array.isArray(spec.nodes) && !misplacedDiagramFields.includes('nodes')) errors.push({ code: 'INVALID_NODES', path: 'nodes', message: `${spec.type} charts require top-level nodes.`, suggestion: 'Pass nodes: [{ id, label }].' });
186
+ if (diagramTypes.has(spec.type)) {
187
+ const diagramInput = { type: spec.type, ...(Array.isArray(spec.nodes) ? { nodes: spec.nodes } : {}), ...(Array.isArray(spec.edges) ? { edges: spec.edges } : {}), ...(Array.isArray(spec.lanes) ? { lanes: spec.lanes } : {}), ...(Array.isArray(spec.groups) ? { groups: spec.groups } : {}), ...(Array.isArray(spec.layers) ? { layers: spec.layers } : {}), ...(Array.isArray(spec.boundaries) ? { boundaries: spec.boundaries } : {}), ...(spec.diagram ? { diagram: spec.diagram } : {}) };
179
188
  const diagramValidation = validateDiagram(diagramInput);
180
189
  diagramValidation.errors.forEach(error => errors.push({ ...error, path: error.path || 'diagram' }));
181
190
  }
@@ -185,14 +194,20 @@ export function validateSpec(input = {}) {
185
194
  rows.forEach((row, index) => { const values = spec.type === 'gantt' ? [row.start, row.end] : [row.date]; if (values.some(value => value != null && Number.isNaN(Date.parse(value)))) errors.push({ code: 'INVALID_DATE', path: `data.values[${index}]`, message: `${spec.type} contains an invalid date.`, suggestion: 'Use an ISO-8601 date such as 2026-09-14.' }); });
186
195
  }
187
196
  if (spec.type === 'gantt') errors.push(...dependencyErrors(spec.data.values));
188
- if (spec.type === 'pie' && spec.data.values.length > 8) warnings.push({ code: 'HIGH_CARDINALITY_PIE', path: 'data.values', message: `Pie contains ${spec.data.values.length} categories.`, expected: '8 or fewer categories', suggestion: 'Use bar/column or group smaller categories.' });
197
+ const rows = Array.isArray(spec.data?.values) ? spec.data.values : [];
198
+ if (spec.type === 'pie' && rows.length > 8) warnings.push({ code: 'HIGH_CARDINALITY_PIE', path: 'data.values', message: `Pie contains ${rows.length} categories.`, expected: '8 or fewer categories', suggestion: 'Use bar/column or group smaller categories.' });
199
+ if (spec.type === 'pie') {
200
+ const valueField = spec.encoding.value?.field || 'value', values = rows.map(row => Number(row?.[valueField])), negativeCount = values.filter(value => Number.isFinite(value) && value < 0).length, positiveTotal = values.reduce((sum, value) => sum + (Number.isFinite(value) && value > 0 ? value : 0), 0);
201
+ if (negativeCount) warnings.push({ code: 'NEGATIVE_VALUE_DROPPED', path: 'encoding.value', count: negativeCount, message: `Pie ignores ${negativeCount} negative value${negativeCount === 1 ? '' : 's'} when calculating shares.`, suggestion: 'Use non-negative part-to-whole values or choose a Cartesian chart for signed measures.' });
202
+ if (!(positiveTotal > 0)) warnings.push({ code: 'ZERO_TOTAL', path: 'encoding.value', message: 'Pie requires a positive total.', suggestion: 'Provide at least one positive value or render an explicit empty state.' });
203
+ }
189
204
  if (spec.type === 'radar' && Array.isArray(spec.indicators) && spec.indicators.some(indicator => !Number.isFinite(Number(indicator.min)) || !Number.isFinite(Number(indicator.max)))) warnings.push({ code: 'AMBIGUOUS_RADAR_DOMAIN', path: 'indicators', message: 'Radar indicator domains are incomplete.', expected: 'finite min and max for every indicator', suggestion: 'Declare explicit domains, especially for mixed units.' });
190
205
  const profile = chartProfiles[spec.type], inputOptions = input && typeof input === 'object' ? input : {};
191
206
  [['legend', 'legend'], ['grid', 'grid'], ['labels', 'labels']].forEach(([option, feature]) => {
192
207
  if (inputOptions[option] !== undefined && profile?.features?.[feature] !== 'supported') warnings.push({ code: `UNSUPPORTED_${option.toUpperCase()}`, path: option, message: `${spec.type} does not support ${option} configuration in the current contract.`, suggestion: 'Remove the option or use a chart type that declares this feature.' });
193
208
  });
194
209
  if ((inputOptions.xAxis !== undefined || inputOptions.yAxis !== undefined) && !['line', 'area', 'bar', 'column', 'scatter'].includes(spec.type)) warnings.push({ code: 'UNSUPPORTED_AXIS', path: inputOptions.xAxis !== undefined ? 'xAxis' : 'yAxis', message: `${spec.type} does not expose Cartesian axis configuration.`, suggestion: 'Use chart-specific options such as domain, colorScale, or indicators.' });
195
- if (spec.type === 'swimlane' && !((Array.isArray(spec.lanes) && spec.lanes.length) || (Array.isArray(spec.data?.lanes) && spec.data.lanes.length))) errors.push({ code: 'MISSING_REQUIRED', path: 'lanes', message: 'Swimlane requires at least one lane.', suggestion: 'Provide lanes: [{ id, label }].' });
210
+ if (spec.type === 'swimlane' && !(Array.isArray(spec.lanes) && spec.lanes.length)) errors.push({ code: 'MISSING_REQUIRED', path: 'lanes', message: 'Swimlane requires at least one top-level lane.', suggestion: 'Provide lanes: [{ id, label }].' });
196
211
  const supportedInteractions = chartProfiles[spec.type]?.interactions || [];
197
212
  Object.entries(spec.interaction || {}).forEach(([name, enabled]) => { if (enabled && !supportedInteractions.includes(name) && !['hover', 'click'].includes(name)) warnings.push({ code: 'UNSUPPORTED_INTERACTION', path: `interaction.${name}`, message: `${name} is not declared for ${spec.type}.`, expected: supportedInteractions, suggestion: 'Disable the interaction or use a compatible chart type.' }); });
198
213
  if (spec.branding != null && typeof spec.branding !== 'boolean' && !(spec.branding && typeof spec.branding === 'object')) {
package/src/theme.mjs CHANGED
@@ -154,9 +154,12 @@ export function planStyle(spec = {}, options = {}) {
154
154
  }
155
155
 
156
156
  function channel(value) {
157
- const text = String(value || '').replace('#', '');
158
- if (!/^[0-9a-f]{6}$/i.test(text)) return null;
159
- return [0, 2, 4].map(index => parseInt(text.slice(index, index + 2), 16) / 255).map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
157
+ const text = String(value || '').trim();
158
+ const hex = text.replace('#', '');
159
+ const rgb = /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)/i.exec(text);
160
+ const values = /^[0-9a-f]{6}$/i.test(hex) ? [0, 2, 4].map(index => parseInt(hex.slice(index, index + 2), 16)) : rgb ? rgb.slice(1, 4).map(Number) : null;
161
+ if (!values || values.some(value => value < 0 || value > 255)) return null;
162
+ return values.map(value => value / 255).map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
160
163
  }
161
164
 
162
165
  function luminance(value) {