@taylorwong/ichartjs 2.0.7 → 2.0.8

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.8 - 2026-09-20
4
+
5
+ - Hardened chart-specific encoding and field validation, including explicit Swimlane requirements and actionable diagnostics for Agents.
6
+ - Added Gauge domain contracts and clamping diagnostics, Heatmap label rendering, locale-aware temporal formatting, semantic Pie labels, and runtime health checkpoints.
7
+ - Added intent fallback suggestions, locale capability metadata, a complete minimal Spec catalog, and synchronized Agent guidance and capability manifests.
8
+
3
9
  ## 2.0.7 - 2026-09-20
4
10
 
5
11
  - Added Architecture and Mindmap chart types with shared diagram contracts, layers, boundaries, parent-child validation, deterministic tree/radial layouts, and Agent-readable schemas and capabilities.
package/README.md CHANGED
@@ -40,7 +40,7 @@ getCapabilities
40
40
  npm install @taylorwong/ichartjs@^2
41
41
  ```
42
42
 
43
- As a fallback for environments without npm access, install directly from GitHub: `npm install github:wanghetommy/ichartjs#v2.0.7`.
43
+ As a fallback for environments without npm access, install directly from GitHub: `npm install github:wanghetommy/ichartjs#v2.0.8`.
44
44
 
45
45
  ### Optional Agent Skill
46
46
 
@@ -62,7 +62,7 @@ For a non-interactive global Codex installation:
62
62
  npx skills add wanghetommy/ichartjs --skill ichartjs --agent codex --global --yes
63
63
  ```
64
64
 
65
- For a release-pinned installation, use `npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.7/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.
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.
66
66
 
67
67
  ### Agent workflow
68
68
 
@@ -0,0 +1,25 @@
1
+ {
2
+ "version": "2.0",
3
+ "name": "minimal-specs",
4
+ "description": "Small validated starting Specs for every public iChart.js chart type.",
5
+ "examples": {
6
+ "line": { "type": "line", "renderer": "svg", "data": [{ "id": "jan", "month": "Jan", "value": 12 }], "encoding": { "x": { "field": "month" }, "y": { "field": "value" } } },
7
+ "area": { "type": "area", "renderer": "svg", "data": [{ "id": "jan", "month": "Jan", "value": 12 }], "encoding": { "x": { "field": "month" }, "y": { "field": "value" } } },
8
+ "bar": { "type": "bar", "renderer": "svg", "data": [{ "id": "a", "name": "A", "value": 12 }], "encoding": { "x": { "field": "name" }, "y": { "field": "value" } } },
9
+ "column": { "type": "column", "renderer": "svg", "data": [{ "id": "a", "name": "A", "value": 12 }], "encoding": { "x": { "field": "name" }, "y": { "field": "value" } } },
10
+ "pie": { "type": "pie", "renderer": "svg", "data": [{ "id": "a", "name": "A", "value": 12 }] },
11
+ "scatter": { "type": "scatter", "renderer": "svg", "data": [{ "id": "a", "x": 1, "y": 12 }], "encoding": { "x": { "field": "x" }, "y": { "field": "y" } } },
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 }] },
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
+ "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
+ "gantt": { "type": "gantt", "renderer": "svg", "data": [{ "id": "task-a", "name": "Task A", "start": "2026-09-01", "end": "2026-09-03" }] },
17
+ "timeline": { "type": "timeline", "renderer": "svg", "data": [{ "id": "event-a", "date": "2026-09-01", "title": "Kickoff" }] },
18
+ "milestone": { "type": "milestone", "renderer": "svg", "data": [{ "id": "release", "date": "2026-09-19", "title": "Release" }] },
19
+ "burndown": { "type": "burndown", "renderer": "svg", "data": [{ "id": "day-1", "date": "2026-09-01", "remaining": 10 }, { "id": "day-2", "date": "2026-09-02", "remaining": 0 }] },
20
+ "flow": { "type": "flow", "renderer": "svg", "nodes": [{ "id": "start", "label": "Start" }, { "id": "done", "label": "Done" }], "edges": [{ "id": "start-done", "from": "start", "to": "done" }] },
21
+ "swimlane": { "type": "swimlane", "renderer": "svg", "lanes": [{ "id": "team", "label": "Team" }], "nodes": [{ "id": "task", "label": "Task", "laneId": "team" }], "edges": [] },
22
+ "architecture": { "type": "architecture", "renderer": "svg", "nodes": [{ "id": "api", "label": "API" }, { "id": "db", "label": "Database" }], "edges": [{ "id": "api-db", "from": "api", "to": "db" }] },
23
+ "mindmap": { "type": "mindmap", "renderer": "svg", "nodes": [{ "id": "root", "label": "Goal" }, { "id": "child", "label": "Next step", "parentId": "root" }], "edges": [] }
24
+ }
25
+ }
@@ -26,6 +26,47 @@ Agent usage and development guide for generic data analysis and metric visualiza
26
26
  - Heatmap treats missing values separately from numeric zero through `colorScale.missing`.
27
27
  - Radar should declare `min` and `max` for every indicator; omitted or mixed-unit domains produce warnings.
28
28
 
29
+ ## Configuration Placement
30
+
31
+ `encoding` describes field roles and series semantics. Keep these options at the Spec level:
32
+
33
+ | Concern | Correct location | Applies to |
34
+ | --- | --- | --- |
35
+ | Axis title and format | `xAxis.title/format`, `yAxis.title/format` | Line, Area, Bar, Column, Scatter |
36
+ | Readable numeric domain | `yAxis.nice`, `yAxis.ticks`, `yAxis.domain` | Line, Area, Bar, Column, Scatter |
37
+ | Data labels | `labels.enabled/format` | Charts that declare `labels` in capabilities |
38
+ | Legend | `legend.visible/position` | Multi-series Cartesian, Pie, and Radar |
39
+ | Gauge domain | `domain: [min, max]` | Gauge |
40
+ | Heatmap color domain | `colorScale.domain` | Heatmap |
41
+ | Radar indicator domain | `indicators[].min/max` | Radar |
42
+
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
+
45
+ ## Encoding Contracts
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`.
48
+
49
+ The complete set of small starting Specs is available at `@taylorwong/ichartjs/recipes/minimal-specs`.
50
+
51
+ ## Intent Vocabulary
52
+
53
+ Pass an exact value from `getCapabilities().intents` to `planChart()`. Common mappings are:
54
+
55
+ | User need | Registered intent | Primary chart |
56
+ | --- | --- | --- |
57
+ | Trend over time | `trend` or `time-series` | Line |
58
+ | Compare categories | `comparison` | Bar |
59
+ | Rank categories | `ranking` | Bar |
60
+ | Distribution or histogram | `distribution` | Column with `bin` transform |
61
+ | Relationship or correlation | `relationship` or `correlation` | Scatter |
62
+ | Composition | `composition` | Column or Area |
63
+ | Matrix intensity | `matrix` or `correlation-grid` | Heatmap |
64
+ | Profile across measures | `multidimensional` or `profile` | Radar |
65
+
66
+ Natural-language prose such as `trend over time` is not an intent token. Map it to `trend` first. An unknown token returns `UNKNOWN_INTENT` plus a fallback plan; never ignore that warning.
67
+
68
+ Unknown intent plans expose `intentKnown: false`, `fallbackUsed: true`, and deterministic `intentSuggestions`. Use those fields to remap the request or ask for confirmation instead of silently accepting the fallback chart.
69
+
29
70
  ## Agent Workflow
30
71
 
31
72
  1. Call `inspectData(data)` to identify fields and missing values.
@@ -33,6 +74,10 @@ Agent usage and development guide for generic data analysis and metric visualiza
33
74
  3. Create a JSON-serializable Chart Spec.
34
75
  4. Call `validateSpec(spec)` before `createChart(spec)`.
35
76
  5. Inspect the result with `chart.describe()` and `chart.getState()`.
77
+ 6. Add stable string `id` values to rows when lineage checks, linked selection, or later updates matter.
78
+ 7. Check `chart.getState().health.renderable`, `health.status`, and `warnings` before presenting the result. `ready` means no material diagnostic is active; `degraded` means the chart rendered with a material warning; `empty` means it has no meaningful result.
79
+
80
+ `locale` defaults to `en-US` and controls axis, label, tooltip, and export formatting. Set `locale: "zh-CN"` for Chinese output. Input dates should remain ISO-8601 strings; natural-language date parsing is not part of the runtime contract.
36
81
 
37
82
  ## Minimal Spec
38
83
 
@@ -41,7 +86,7 @@ Agent usage and development guide for generic data analysis and metric visualiza
41
86
  type: 'line',
42
87
  renderer: 'svg',
43
88
  container: '#chart',
44
- data: { values: [{ month: 'Jan', sales: 120 }] },
89
+ data: { values: [{ id: 'jan', month: 'Jan', sales: 120 }] },
45
90
  encoding: {
46
91
  x: { field: 'month', type: 'category' },
47
92
  y: { field: 'sales', type: 'quantitative' }
@@ -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.7/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.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.
37
37
 
38
38
  Verify discovery with `npx skills add wanghetommy/ichartjs --list`; the result should include `ichartjs`.
39
39
 
@@ -1,6 +1,6 @@
1
1
  # iChart.js 2.0 Roadmap
2
2
 
3
- > Roadmap baseline: 2026-09-14. Current release status: `v2.0.7` includes the completed Iteration 12 structured-diagram work and follow-up chart/menu fixes. Geographic charts and 3D rendering remain out of scope until explicitly reintroduced.
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.
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.7`.
13
+ For environments without npm registry access, install from GitHub as a fallback: `npm install github:wanghetommy/ichartjs#v2.0.8`.
14
14
 
15
15
  Use the package through a bundler or another environment that resolves npm ESM imports:
16
16
 
@@ -24,6 +24,7 @@ Agent usage and development guide for project planning, delivery tracking, and p
24
24
  - Keep derived values separate from source rows. State and tooltips may expose variance, float, warnings, and assumptions, but transforms must not mutate source data.
25
25
  - Linked filters and linked selection must use stable record IDs, not array positions.
26
26
  - Forecasts, risk scores, and aging buckets are inspectable heuristics. They are not commitments, causal claims, or hidden inference.
27
+ - Project chart date axes are derived from `date`, `start`, and `end` records in the current contract; generic `xAxis.title/format` and `xAxis.min/max` settings do not customize them.
27
28
 
28
29
  ## Agent Workflow
29
30
 
@@ -67,6 +67,12 @@ Call `planChart(rows, { intent, renderer })`. Read the full result rather than o
67
67
 
68
68
  Stop before rendering when `requiredFields` is not empty. Ask for the missing information or choose a supported alternative without fabricating data.
69
69
 
70
+ #### Use registered intents only
71
+
72
+ `intent` is an exact machine token, not a natural-language sentence. Discover the allowlist from `getCapabilities().intents`, then pass values such as `trend`, `time-series`, `comparison`, `ranking`, `distribution`, `relationship`, `matrix`, `multidimensional`, `schedule`, `architecture`, or `mindmap`. Do not pass `trend over time` or `show a sales trend` directly. If an unknown token is passed, `planChart()` returns an `UNKNOWN_INTENT` warning and a safe fallback, which may select the wrong chart if the warning is ignored.
73
+
74
+ If the user gives prose, map it to a registered token before calling `planChart()` and preserve the original prose separately as user intent.
75
+
70
76
  ### 4. Build a JSON-Friendly Spec
71
77
 
72
78
  For a trend or comparison with one dimension and one or more measures:
@@ -97,6 +103,28 @@ const spec = {
97
103
 
98
104
  Use the selected capability and recipes for Pie, Gauge, Heatmap, Radar, project views, and diagrams because their required encodings differ.
99
105
 
106
+ #### Put options at the contract level
107
+
108
+ Keep `encoding` for field roles and series semantics. Put presentation and axis options at the Spec level:
109
+
110
+ | Need | Correct location | Common mistake |
111
+ | --- | --- | --- |
112
+ | Axis title | `xAxis.title`, `yAxis.title` | `encoding.x.title`, `encoding.y.title` |
113
+ | Axis number/date format | `xAxis.format`, `yAxis.format` | `encoding.x.format`, `encoding.y.format` |
114
+ | Data labels | `labels.enabled`, `labels.format` | `encoding.labels` |
115
+ | Legend | `legend.visible` | `encoding.legend` |
116
+ | Theme and palette | `theme.mode`, `theme.preset`, `theme.palette` | series or encoding color guesses |
117
+
118
+ `validateSpec()` reports these misplaced options as structured warnings. Repair them before presenting the chart. A single-series Cartesian chart shows the encoded field name in the legend by default; set `legend: { visible: false }` when that label adds no value.
119
+
120
+ #### Know the domain contract
121
+
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
+
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.
125
+
126
+ 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
+
100
128
  ### 5. Validate
101
129
 
102
130
  ```js
@@ -177,6 +205,8 @@ Before returning a result, verify:
177
205
  - the branding on/off state is documented so live view and exports stay consistent;
178
206
  - the preview URL or output artifact (JSON/SVG/PNG/JPEG) is provided to the user.
179
207
 
208
+ For deterministic lineage checks and linked updates, give every input row a stable string `id`. Without one, the runtime uses a positional fallback such as `record-0`; that is sufficient for a local render but should not be treated as a durable business identity.
209
+
180
210
  ## Branding (Signature) Defaults
181
211
 
182
212
  - Default `branding: true`: a low-contrast `Powered by iChart.js` signature appears in the bottom-right corner, synchronized across live rendering, PNG/SVG raster export, and JSON state persistence.
@@ -14,14 +14,19 @@ For post-creation visual settings, use `getPreferenceCapabilities(chartType, { l
14
14
 
15
15
  Iteration 8 adds per-chart profiles through `getChartCapability(type)`. Each profile declares required data roles, supported interactions, renderers, feature status, exports, and practical limits. Unsupported behavior must be handled from this profile or from validation diagnostics rather than guessed.
16
16
 
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. Planning never invents business meaning, units, dates, or missing fields.
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
+
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.
18
20
 
19
21
  ## Spec Rules
20
22
 
21
23
  - Specs must be JSON-serializable.
22
24
  - Call `validateSpec()` before rendering.
23
25
  - Chart layout and data semantics are renderer-independent.
26
+ - Keep axis titles/formats on `xAxis`/`yAxis`, labels on `labels`, and legend settings on `legend`; misplaced options return structured warnings.
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`.
24
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`.
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.
25
30
 
26
31
  ## Renderer
27
32
 
@@ -119,6 +124,8 @@ chart.downloadJSON()
119
124
 
120
125
  Validation results contain separate `errors`, `warnings`, and `normalizations`. Diagnostics use stable codes, JSON-oriented paths, expected values where useful, and actionable suggestions. `chart.explain()` returns encodings, transforms, interactions, assumptions, warnings, stable record lineage, and an accessibility summary.
121
126
 
127
+ For lineage checks and linked updates, provide stable string `id` values on input rows. Without one, the runtime uses deterministic positional IDs such as `record-0`; these are suitable for a local self-check but not for durable business identity.
128
+
122
129
  ## Interaction
123
130
 
124
131
  Common interactions include Tooltip, Hover, Click, Selection, Zoom, Pan, Drag, Touch, and Keyboard; each chart's enabled interactions are controlled by its Spec and capability declaration.
@@ -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.7/skills/ichartjs \
124
+ npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.8/skills/ichartjs \
125
125
  --agent codex --global --yes
126
126
  ```
127
127
 
@@ -26,6 +26,32 @@
26
26
  - Heatmap 通过 `colorScale.missing` 区分缺失值和数值零。
27
27
  - Radar 应为每个指标声明 `min` 和 `max`;缺失域或混合单位会产生告警。
28
28
 
29
+ ## 配置项位置
30
+
31
+ `encoding` 只描述字段角色和 Series 语义。以下配置应放在 Spec 顶层:
32
+
33
+ | 配置 | 正确位置 | 适用范围 |
34
+ | --- | --- | --- |
35
+ | 坐标轴标题和格式 | `xAxis.title/format`、`yAxis.title/format` | Line、Area、Bar、Column、Scatter |
36
+ | 易读数值域 | `yAxis.nice`、`yAxis.ticks`、`yAxis.domain` | Line、Area、Bar、Column、Scatter |
37
+ | 数据标签 | `labels.enabled/format` | 能力清单声明支持 labels 的图表 |
38
+ | 图例 | `legend.visible/position` | 多系列笛卡尔图、Pie、Radar |
39
+ | Gauge 坐标域 | `domain: [min, max]` | Gauge |
40
+ | Heatmap 颜色域 | `colorScale.domain` | Heatmap |
41
+ | Radar 指标域 | `indicators[].min/max` | Radar |
42
+
43
+ 不要把 `title`、`format`、`labels` 或 `legend` 放在 `encoding` 中;`validateSpec()` 会报告结构化警告。数值纵轴默认使用易读域(`nice: true`、`ticks: "auto"`);需要固定范围时使用 `yAxis.domain: [min, max]`,需要保留原始边界时使用 `yAxis.nice: false`。分类/时间横轴的 `min/max` 和 `domain` 不支持。
44
+
45
+ ## Encoding 契约
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`。
48
+
49
+ ## 意图注册词
50
+
51
+ 传给 `planChart()` 的 `intent` 必须是 `getCapabilities().intents` 中的精确值,而不是用户原句。比如把“展示销售随时间变化”映射为 `trend`;未知词会返回 `UNKNOWN_INTENT` 和降级方案,不能忽略该警告。
52
+
53
+ 未知词的规划结果还会给出 `intentKnown: false`、`fallbackUsed: true` 和 `intentSuggestions`,应据此重新映射或请求确认。
54
+
29
55
  ## Agent 流程
30
56
 
31
57
  1. 使用 `inspectData(data)` 检查字段和缺失值。
@@ -33,6 +59,10 @@
33
59
  3. 生成 JSON-serializable Chart Spec。
34
60
  4. 先调用 `validateSpec(spec)`,再调用 `createChart(spec)`。
35
61
  5. 使用 `chart.describe()` 和 `chart.getState()` 检查结果。
62
+ 6. 如果需要 lineage、联动筛选或后续更新,为每条记录提供稳定字符串 `id`;没有 `id` 时 Runtime 会使用 `record-0` 这类确定性后备值。
63
+ 7. 展示前检查 `chart.getState().health.renderable`、`health.status` 和 `warnings`。`ready` 表示没有阻止渲染的问题,`degraded` 表示带诊断继续渲染,`empty` 表示结果没有可用内容。
64
+
65
+ `locale` 默认是 `en-US`,需要中文数字/日期输出时设置 `locale: "zh-CN"`。输入日期保持 ISO-8601 字符串;运行时不负责自然语言日期解析。
36
66
 
37
67
  ## 最小 Spec
38
68
 
@@ -40,7 +70,7 @@
40
70
  {
41
71
  type: 'line',
42
72
  renderer: 'svg',
43
- data: { values: [{ month: 'Jan', sales: 120 }] },
73
+ data: { values: [{ id: 'jan', month: 'Jan', sales: 120 }] },
44
74
  encoding: {
45
75
  x: { field: 'month', type: 'category' },
46
76
  y: { field: 'sales', type: 'quantitative' }
@@ -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.7/skills/ichartjs`。WorkBuddy 可通过自身 Skill 界面导入该带 Tag 的目录;只有当前 CLI 明确声明对应适配器时才使用宿主专用 `--agent` 参数。
25
+ Codex 全局无交互安装可追加 `--agent codex --global --yes`。需要固定发布版本时,安装 `https://github.com/wanghetommy/ichartjs/tree/v2.0.8/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.7`。
9
+ 无法访问 npm Registry 的环境请用 GitHub 源作为后备:`npm install github:wanghetommy/ichartjs#v2.0.8`。
10
10
 
11
11
  ```js
12
12
  import { createChart } from '@taylorwong/ichartjs';
@@ -36,6 +36,34 @@ Agent 与开发者使用同一个 ESM 入口。编码 Agent 的完整方式见 [
36
36
  6. 仅在 `validation.valid` 为 `true` 时调用 `createChart()`。
37
37
  7. 用 `chart.explain()`、`chart.getState()`、JSON/SVG/PNG export 完成自检。需要持久化或附件生成时使用 `chart.export({type:'json'|'svg'|'png'})`,在浏览器环境可调用 `chart.downloadPNG()` / `chart.downloadSVG()` / `chart.downloadJSON()` 触发保存,最后调用 `chart.destroy()`。
38
38
 
39
+ ### 意图必须使用注册词
40
+
41
+ `intent` 是精确的机器词,不是自然语言句子。先从 `getCapabilities().intents` 获取允许值,再传入 `trend`、`time-series`、`comparison`、`ranking`、`distribution`、`relationship`、`matrix`、`multidimensional`、`schedule`、`architecture` 或 `mindmap` 等值。不要直接传入 `trend over time` 或 `展示销售趋势`。未知词会返回 `UNKNOWN_INTENT` 警告并安全降级;如果忽略警告,可能选错图表。
42
+
43
+ 如果用户输入的是自然语言,先映射为注册词,再调用 `planChart()`,同时保留原始用户意图用于展示。
44
+
45
+ ### 配置项放在 Spec 正确层级
46
+
47
+ `encoding` 只描述字段角色和 Series 语义,标题、格式和显示组件放在 Spec 顶层:
48
+
49
+ | 需求 | 正确位置 | 常见错误 |
50
+ | --- | --- | --- |
51
+ | 坐标轴标题 | `xAxis.title`、`yAxis.title` | `encoding.x.title`、`encoding.y.title` |
52
+ | 坐标轴格式 | `xAxis.format`、`yAxis.format` | `encoding.x.format`、`encoding.y.format` |
53
+ | 数据标签 | `labels.enabled`、`labels.format` | `encoding.labels` |
54
+ | 图例 | `legend.visible` | `encoding.legend` |
55
+ | 主题和配色 | `theme.mode`、`theme.preset`、`theme.palette` | 随意猜测 Series 颜色 |
56
+
57
+ `validateSpec()` 会把这些错误位置报告为结构化警告,应先修复再展示。单系列笛卡尔图默认会把字段名作为图例;不需要时使用 `legend: { visible: false }`。
58
+
59
+ ### 当前坐标域限制
60
+
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
+
63
+ 图表通道是严格按类型定义的:笛卡尔图表使用 `x`/`y`,Pie/Funnel/Gauge 使用 `category`/`value`,Heatmap 使用 `x`/`y`/`color`,Radar 使用 `indicators[].field`。缺失字段或不支持的通道会校验失败;Gauge 必须声明 `domain`。新建图表可直接使用 `@taylorwong/ichartjs/recipes/minimal-specs` 中的最小目录。
64
+
65
+ Agent 自检使用 `chart.getState().health` 和 `chart.explain().health`,其中包含 `ready`、`degraded`、`empty`、警告数、隐藏标签数、限制值数和已渲染标记数。`locale` 默认 `en-US`,需要中文输出时设置 `locale: "zh-CN"`,输入日期仍使用 ISO-8601。
66
+
39
67
  ## 品牌署名(Branding)默认行为
40
68
 
41
69
  - 默认 `branding: true`:在画面与所有导出产物(PNG/SVG/JSON)右下角同步出现 `Powered by iChart.js` 低对比度署名。
@@ -73,6 +101,8 @@ const headlessPng = await chart.exportAsync({ type: 'png' });
73
101
 
74
102
  不要虚构字段、单位、日期、依赖关系、日历规则或预测置信度。Radar 使用混合单位时必须提供显式 domain;Heatmap 必须区分缺失值和零;高基数占比数据优先使用 Bar 而不是 Pie。
75
103
 
104
+ 为了让 lineage 自检和联动更新稳定,建议每条输入记录提供稳定字符串 `id`。没有 `id` 时 Runtime 会使用 `record-0` 这类位置后备值,只适合本地展示,不应当视为持久业务身份。
105
+
76
106
  ## 完整示例
77
107
 
78
108
  ```bash
@@ -16,6 +16,8 @@ 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`。
20
+
19
21
  `validateSpec()` 分开返回 `errors`、`warnings` 和 `normalizations`;诊断包含稳定代码、JSON 路径、期望值和修复建议。`chart.explain()` 返回编码、转换、交互、假设、警告、稳定记录血缘和无障碍摘要。
20
22
 
21
23
  ## 关键规则
@@ -25,6 +27,8 @@ Iteration 8 通过 `getChartCapability(type)` 提供逐图表能力档案,包
25
27
  - 布局和数据语义不依赖 Renderer。
26
28
  - 通用图表使用 `data.values`;Flow/Swimlane 使用 `nodes/edges/lanes`。
27
29
  - `svg` 适合 DOM 交互和可访问性;`canvas` 适合大量图元和绘制性能。
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
+ - `chart.getState().health` 和 `chart.explain().health` 提供 `ready`、`degraded` 或 `empty`,以及可渲染性、问题代码、警告数、隐藏标签数、限制值数和已渲染标记数。`locale` 默认 `en-US`,可设置 `zh-CN` 影响输出格式;输入日期应使用 ISO-8601 字符串。
28
32
 
29
33
  ## 品牌署名(Branding)
30
34
 
@@ -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.7/skills/ichartjs \
119
+ npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.8/skills/ichartjs \
120
120
  --agent codex --global --yes
121
121
  ```
122
122
 
@@ -8,6 +8,7 @@
8
8
  "agentQuickstart": "docs/agent/quickstart.md",
9
9
  "usageScenarios": "docs/agent/usage-scenarios.md",
10
10
  "agentWorkflowExample": "examples/agent-workflow.mjs",
11
+ "minimalSpecs": "@taylorwong/ichartjs/recipes/minimal-specs",
11
12
  "officialSkill": "skills/ichartjs/SKILL.md",
12
13
  "scenarios": {
13
14
  "charting": ["line", "area", "bar", "column", "pie", "scatter", "funnel", "gauge", "heatmap", "radar"],
@@ -17,6 +18,17 @@
17
18
  "projectIntelligence": ["variance", "capacity", "release", "risk", "aging"],
18
19
  "chartModes": ["stacked", "percent-stacked", "donut", "mixed-line-column", "bin", "architecture-layers", "architecture-boundaries", "mindmap-tree", "mindmap-radial", "mindmap-curved-edges"],
19
20
  "renderers": ["canvas", "svg"],
21
+ "locale": {
22
+ "default": "en-US",
23
+ "recommended": ["en-US", "zh-CN"],
24
+ "appliesTo": ["axis", "labels", "tooltip", "export"],
25
+ "inputDates": "ISO-8601 strings; natural-language date parsing is not supported."
26
+ },
27
+ "health": {
28
+ "state": ["ready", "degraded", "empty"],
29
+ "apis": ["chart.getState().health", "chart.explain().health"],
30
+ "metrics": ["warnings", "suppressedLabels", "clampedValues", "renderedMarks"]
31
+ },
20
32
  "interactionDefaults": { "zoom": false, "pan": false, "brush": false, "drag": false, "edgeDrag": false, "portConnect": false, "editing": false },
21
33
  "diagramEditing": {
22
34
  "renderers": ["canvas", "svg"],
@@ -57,6 +57,7 @@ export function runAgentWorkflow(rows, options = {}) {
57
57
  try {
58
58
  const explanation = chart.explain();
59
59
  const state = chart.getState();
60
+ const expectedRecordIds = rows.map((row, index) => String(row.id ?? row.key ?? `record-${index}`));
60
61
  return {
61
62
  ok: true,
62
63
  stage: 'complete',
@@ -69,7 +70,7 @@ export function runAgentWorkflow(rows, options = {}) {
69
70
  state,
70
71
  selfCheck: {
71
72
  chartDeclared: capabilities.chartTypes.includes(plan.primary),
72
- recordIdsPreserved: rows.every(row => explanation.lineage.recordIds.includes(row.id)),
73
+ recordIdsPreserved: expectedRecordIds.every(recordId => explanation.lineage.recordIds.includes(recordId)),
73
74
  warningsVisible: plan.warnings.every(warning => state.warnings.some(item => item.code === warning.code)),
74
75
  styleExplained: explanation.style?.preset === plan.styleRecommendation.preset
75
76
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taylorwong/ichartjs",
3
- "version": "2.0.7",
3
+ "version": "2.0.8",
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.7/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.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.
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
 
@@ -36,15 +36,18 @@ Do not install the unscoped npm registry package named `ichartjs`; it is current
36
36
  1. Locate the package or repository root. Read `docs/agent/quickstart.md` when available.
37
37
  2. Call `getCapabilities()` before selecting a chart or interaction.
38
38
  3. Call `inspectData()` and preserve stable record IDs.
39
- 4. Call `planChart(data, { intent, renderer, context })` and inspect the complete result, including `styleRecommendation`.
40
- 5. Stop when `requiredFields` is non-empty; request data or explain a supported alternative.
41
- 6. Build a JSON-serializable Spec using `suggestedEncodings`, the selected capability, and an applicable recipe.
42
- 7. Call `validateSpec()` before rendering. Repair only from structured diagnostics.
43
- 8. Call `createChart()` only after validation succeeds.
44
- 9. Self-check with `chart.explain()`, `chart.getState()`, and JSON export.
45
- 10. Provide an exact preview URL or artifact path and report assumptions, warnings, and deferred checks.
46
- 11. Prefer `theme: { mode: 'auto', preset, palette }`; preserve explicit user style choices and use `chart.setTheme()` for live switching.
47
- 12. 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`.
39
+ 4. Discover `getCapabilities().intents`; map natural-language requests to an exact registered token before calling `planChart(data, { intent, renderer, context })`.
40
+ 5. Inspect the complete planning result, including `styleRecommendation`, warnings, and fallback status.
41
+ 6. Stop when `requiredFields` is non-empty; request data or explain a supported alternative.
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.
44
+ 9. Keep titles/formats under `xAxis`/`yAxis`, labels under `labels`, and legend under `legend`; do not place them inside `encoding`.
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
+ 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`.
48
51
 
49
52
  Use `@taylorwong/ichartjs` for package imports. Use `examples/agent-workflow.mjs` as the executable baseline when working in the repository.
50
53
 
@@ -69,9 +72,15 @@ Route by requested output:
69
72
 
70
73
  - Never invent fields, units, dates, dependencies, calendar rules, domains, or forecast confidence.
71
74
  - Never silently drop validation errors, warnings, assumptions, normalizations, or unsupported requests.
75
+ - Treat `getCapabilities().intents` as an allowlist; never pass a natural-language sentence as `planChart().intent`.
76
+ - If planning returns `fallbackUsed: true`, use `intentSuggestions` to remap or ask for confirmation; never silently accept the fallback chart.
77
+ - Keep axis titles/formats under `xAxis`/`yAxis`, labels under `labels`, and legend settings under `legend`.
78
+ - Repair `UNKNOWN_INTENT`, misplaced-option, and unsupported-axis warnings before presenting a chart. For numeric y-axes, prefer the default readable domain; use `yAxis.domain: [min, max]` for an explicit range, `yAxis.nice: false` for raw boundaries, and `yAxis.ticks` for a stable label count.
79
+ - Add stable string `id` values to tabular rows when lineage or linked updates are part of the deliverable.
72
80
  - Avoid Pie for high-cardinality categories; prefer Bar for comparison.
73
81
  - Require explicit Radar domains when units differ.
74
82
  - Distinguish missing Heatmap values from zero.
83
+ - Set `locale` explicitly when output needs localization; the default is `en-US`, and input dates must be ISO-8601 strings.
75
84
  - Use categorical, sequential, diverging, or status palettes by data semantics; do not invent arbitrary color sets or rely on color alone.
76
85
  - Surface theme contrast diagnostics and high-cardinality color warnings.
77
86
  - Do not generate Map or 3D Specs unless capabilities explicitly add them.
@@ -11,11 +11,11 @@ getCapabilities → inspectData → planChart → build Spec → validateSpec
11
11
  - `getCapabilities()`: global and per-chart discoverability.
12
12
  - `getChartCapability(type)`: required roles, interactions, renderers, features, exports, and limits.
13
13
  - `inspectData(input)`: field roles, identifiers, cardinality, missingness, temporal coverage, and warnings.
14
- - `planChart(input, options)`: primary type, alternatives, confidence, reasons, required fields, suggested encodings, assumptions, warnings, unsupported requests, and next actions.
14
+ - `planChart(input, options)`: primary type, alternatives, confidence, reasons, required fields, suggested encodings, assumptions, warnings, unsupported requests, next actions, and `intentKnown`/`intentSuggestions`/`fallbackUsed` metadata.
15
15
  - `validateSpec(spec)`: errors, warnings, normalizations, and normalized Spec.
16
16
  - `createChart(spec)`: headless or mounted chart lifecycle.
17
17
  - `chart.explain()`: semantics, encodings, interactions, lineage, warnings, and accessibility summary.
18
- - `chart.getState()`: renderer, dimensions, warnings, assumptions, selection, view, project analytics, and linked state.
18
+ - `chart.getState()`: renderer, dimensions, warnings, assumptions, selection, view, project analytics, linked state, and `health`.
19
19
  - `chart.export({ type: 'json' })`: JSON-safe Spec and state.
20
20
 
21
21
  ## Stop conditions
@@ -24,6 +24,8 @@ Do not render when:
24
24
 
25
25
  - `plan.requiredFields` is non-empty;
26
26
  - `validateSpec().valid` is false;
27
+ - chart-specific encoding channels are unsupported or reference missing fields;
28
+ - a Gauge has no explicit `domain`;
27
29
  - the requested chart, renderer, interaction, or export is not declared;
28
30
  - required project dates, diagram endpoints, ports, lanes, or business schema rules are invalid.
29
31
 
@@ -35,5 +37,6 @@ Verify that:
35
37
  - validation passed without ignored errors;
36
38
  - explanation lineage preserves stable source IDs;
37
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;
38
41
  - the preview uses a maintained URL;
39
42
  - replaced charts are destroyed.
@@ -20,3 +20,5 @@
20
20
  | Responsibility | Swimlane | Require valid lane membership. |
21
21
 
22
22
  Always prefer `planChart()` over this table when runtime capability output is available. Treat alternatives as tradeoffs, not automatic fallbacks.
23
+
24
+ `planChart()` accepts the exact registered intent token, not the user's full sentence. For example, map “show the sales trend over time” to `trend`; if an unknown token is passed, inspect and surface the returned `UNKNOWN_INTENT` warning instead of accepting the fallback silently.
@@ -47,12 +47,27 @@ const intentMap = {
47
47
  schedule: 'gantt', variance: 'gantt', timeline: 'timeline', milestone: 'milestone', progress: 'burndown', release: 'burndown', capacity: 'column', risk: 'scatter', aging: 'bar', matrix: 'heatmap', 'correlation-grid': 'heatmap', multidimensional: 'radar', profile: 'radar', workflow: 'flow', responsibility: 'swimlane', architecture: 'architecture', 'business-architecture': 'architecture', 'data-architecture': 'architecture', 'technical-architecture': 'architecture', mindmap: 'mindmap', hierarchy: 'mindmap', brainstorm: 'mindmap', trend: 'line', 'time-series': 'line', 'part-to-whole': 'pie', distribution: 'column', relationship: 'scatter', correlation: 'scatter', funnel: 'funnel', conversion: 'funnel', 'single-value': 'gauge', ranking: 'bar', comparison: 'bar', composition: 'column'
48
48
  };
49
49
 
50
+ const intentAliases = {
51
+ trend: ['trend', 'time-series'], time: ['trend', 'time-series'], compare: ['comparison', 'ranking'], rank: ['ranking', 'comparison'],
52
+ distribution: ['distribution', 'comparison'], relationship: ['relationship', 'correlation'], correlation: ['correlation', 'relationship'],
53
+ matrix: ['matrix', 'correlation-grid'], profile: ['multidimensional', 'profile'], architecture: ['architecture', 'business-architecture', 'data-architecture', 'technical-architecture'],
54
+ mind: ['mindmap', 'hierarchy', 'brainstorm']
55
+ };
56
+
50
57
  const alternatives = {
51
58
  heatmap: ['scatter', 'column'], radar: ['bar', 'line'], gantt: ['timeline', 'milestone'], timeline: ['milestone', 'gantt'], milestone: ['timeline', 'gantt'], burndown: ['line', 'gantt'], column: ['bar', 'line'], scatter: ['bar', 'line'], bar: ['column', 'line'], flow: ['swimlane', 'architecture'], swimlane: ['flow', 'architecture'], architecture: ['flow', 'mindmap'], mindmap: ['architecture', 'flow'], line: ['area', 'column'], pie: ['bar', 'column'], funnel: ['bar', 'column'], gauge: ['column', 'bar']
52
59
  };
53
60
 
54
61
  function warning(code, path, message, suggestion) { return { code, path, message, ...(suggestion ? { suggestion } : {}) }; }
55
62
 
63
+ function suggestIntents(requested) {
64
+ const value = String(requested || '').trim().toLowerCase();
65
+ if (!value) return ['comparison'];
66
+ if (intentAliases[value]) return intentAliases[value];
67
+ const tokens = value.split(/[^a-z0-9-]+/).filter(Boolean);
68
+ return [...new Set(tokens.flatMap(token => intentAliases[token] || (intentMap[token] ? [token] : [])))].slice(0, 5);
69
+ }
70
+
56
71
  export function getChartCapability(type) {
57
72
  return chartProfiles[type] ? JSON.parse(JSON.stringify(chartProfiles[type])) : null;
58
73
  }
@@ -104,10 +119,12 @@ export function planChart(input, options = {}) {
104
119
  const report = inspectData(input);
105
120
  const requestedIntent = options.intent || 'comparison';
106
121
  const warnings = [...report.warnings];
122
+ const intentKnown = Boolean(intentMap[requestedIntent]);
123
+ const intentSuggestions = intentKnown ? [] : suggestIntents(requestedIntent);
107
124
  let primary = intentMap[requestedIntent];
108
125
  if (!primary) {
109
126
  primary = report.fields.some(field => field.type === 'temporal') ? 'line' : report.measures.length >= 2 && report.dimensions.length === 0 ? 'scatter' : 'bar';
110
- warnings.push(warning('UNKNOWN_INTENT', 'intent', `Intent ${requestedIntent} is not registered.`, 'Use an intent returned by getCapabilities().intents.'));
127
+ warnings.push(warning('UNKNOWN_INTENT', 'intent', `Intent ${requestedIntent} is not registered.`, intentSuggestions.length ? `Use one of: ${intentSuggestions.join(', ')}.` : 'Use an intent returned by getCapabilities().intents.'));
111
128
  }
112
129
  const profile = chartProfiles[primary];
113
130
  const requiredFields = [];
@@ -138,6 +155,9 @@ export function planChart(input, options = {}) {
138
155
  return {
139
156
  version: '1.0',
140
157
  intent: requestedIntent,
158
+ intentKnown,
159
+ intentSuggestions,
160
+ fallbackUsed: !intentKnown,
141
161
  primary,
142
162
  alternatives: alternatives[primary] || ['column', 'line'],
143
163
  confidence,
@@ -170,6 +190,7 @@ export function explainChart(spec, model = {}) {
170
190
  interactions: Object.keys(spec.interaction || {}).filter(key => spec.interaction[key]),
171
191
  assumptions: [...(model.data?.assumptions || []), ...(model.state?.projectAnalytics?.assumptions || [])],
172
192
  warnings,
193
+ axes: model.state?.axes || null,
173
194
  style: spec.theme && typeof spec.theme === 'object' ? { name: spec.theme.name, preset: spec.theme.preset, mode: spec.theme.mode, resolvedMode: spec.theme.resolvedMode, palette: spec.theme.palette, reasons: spec.theme.reasons || [], warnings: spec.theme.warnings || [] } : planStyle(spec),
174
195
  lineage: { recordIds: (model.data?.rows || []).map((row, index) => String(row.id ?? row.key ?? `record-${index}`)), sourcePreserved: true },
175
196
  accessibility: { enabled: Boolean(spec.accessibility?.enabled), summary: spec.accessibility?.description || spec.title?.text || `${spec.type} chart with ${model.data?.rows?.length || 0} data items.` }
@@ -208,6 +229,7 @@ export function getCapabilities() {
208
229
  chartTypes,
209
230
  charts: JSON.parse(JSON.stringify(chartProfiles)),
210
231
  intents,
232
+ locale: { default: 'en-US', recommended: ['en-US', 'zh-CN'], appliesTo: ['axis', 'labels', 'tooltip', 'export'], inputDates: 'ISO-8601 strings; natural-language date parsing is not supported.' },
211
233
  chartModes: { stack: ['stacked', 'percent'], pie: ['standard', 'donut'], composition: ['multi-series', 'mixed-line-column', 'dual-axis'], transforms: ['bin'], diagrams: ['process', 'architecture', 'mindmap'], mindmapLayouts: ['tree', 'radial'], mindmapEdges: ['curved', 'straight', 'orthogonal'] },
212
234
  projectManagement: project,
213
235
  projectIntelligence: {
package/src/charts.mjs CHANGED
@@ -12,6 +12,58 @@ import { axisLabelLayout, boxesOverlap, estimateTextWidth, fontSize, titleLayout
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;
15
+ const niceMantissas = [1, 1.2, 1.5, 1.8, 2, 2.5, 3, 4, 5, 6, 7.5, 8, 10];
16
+
17
+ function nearestNiceMantissa(value) {
18
+ return niceMantissas.reduce((best, candidate) => Math.abs(Math.log(value / candidate)) < Math.abs(Math.log(value / best)) ? candidate : best, niceMantissas[0]);
19
+ }
20
+
21
+ function niceCeil(value) {
22
+ const positive = Math.abs(Number(value));
23
+ if (!(positive > 0) || !Number.isFinite(positive)) return 1;
24
+ const power = 10 ** Math.floor(Math.log10(positive));
25
+ const normalized = positive / power;
26
+ const mantissa = niceMantissas.find(candidate => candidate >= normalized) || 10;
27
+ return mantissa * power;
28
+ }
29
+
30
+ function validDomain(domain) {
31
+ return Array.isArray(domain) && domain.length === 2 && domain.every(value => Number.isFinite(Number(value))) && Number(domain[1]) > Number(domain[0]);
32
+ }
33
+
34
+ function resolveAxisDomain(rawMin, rawMax, axis = {}, type = 'linear') {
35
+ if (validDomain(axis.domain)) return [Number(axis.domain[0]), Number(axis.domain[1])];
36
+ if (type === 'log') return [rawMin, rawMax];
37
+ let min = rawMin, max = rawMax;
38
+ if (axis.nice !== false) {
39
+ min = min < 0 ? -niceCeil(-min) : 0;
40
+ max = niceCeil(max);
41
+ }
42
+ if (!(max > min)) max = min + (niceCeil(Math.abs(min) || 1) || 1);
43
+ return [min, max];
44
+ }
45
+
46
+ function axisTicks(min, max, axis = {}, type = 'linear') {
47
+ if (!(max > min)) return [min, max];
48
+ if (type === 'log') {
49
+ const start = Math.ceil(Math.log10(Math.max(min, 0.000001)));
50
+ const end = Math.floor(Math.log10(Math.max(max, 0.000001)));
51
+ const powers = Array.from({ length: Math.max(0, end - start + 1) }, (_, index) => 10 ** (start + index));
52
+ return [...new Set([min, ...powers.filter(value => value > min && value < max), max])];
53
+ }
54
+ const explicitCount = Number.isInteger(axis.ticks) && axis.ticks >= 2 ? axis.ticks : null;
55
+ const targetIntervals = explicitCount ? explicitCount - 1 : 5;
56
+ let intervals = explicitCount ? targetIntervals : 4, bestScore = Infinity;
57
+ if (!explicitCount) {
58
+ for (let candidate = 4; candidate <= 7; candidate += 1) {
59
+ const step = (max - min) / candidate;
60
+ const power = 10 ** Math.floor(Math.log10(Math.abs(step)));
61
+ const score = Math.abs(Math.log((step / power) / nearestNiceMantissa(step / power))) + Math.abs(candidate - targetIntervals) * 0.08;
62
+ if (score < bestScore) { bestScore = score; intervals = candidate; }
63
+ }
64
+ }
65
+ return Array.from({ length: intervals + 1 }, (_, index) => min + (max - min) * index / intervals);
66
+ }
15
67
 
16
68
  function chromeLayout(spec, entries = []) {
17
69
  const title = titleLayout(spec);
@@ -62,7 +114,7 @@ function layout(spec, data, legendEntries = []) {
62
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;
63
115
  const plotTop = Math.max(p.top, chrome.bottom + (chrome.bottom ? 12 : 0));
64
116
  const plotWidth = Math.max(1, width - plotLeft - p.right);
65
- const axisLabelTexts = temporal ? timeTicks(xMin, xMax, 5).map(value => formatTime(value, xMax - xMin)) : quantitativeX ? timeTicks(xMin, xMax, 5).map(value => formatValue(value, spec.xAxis?.format)) : categories;
117
+ 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;
66
118
  const xLabels = axisLabelLayout(spec, spec.type === 'bar' ? [] : axisLabelTexts, plotWidth);
67
119
  const bottomReserve = Math.max(p.bottom, Math.ceil(16 + xLabels.projectedHeight + (spec.xAxis?.title ? 20 : 0)));
68
120
  const plot = { x: plotLeft, y: plotTop, width: plotWidth, height: Math.max(1, height - plotTop - bottomReserve) };
@@ -75,19 +127,23 @@ function layout(spec, data, legendEntries = []) {
75
127
  return [values.filter(value => value >= 0).reduce((sum, value) => sum + value, 0), values.filter(value => value < 0).reduce((sum, value) => sum + value, 0)];
76
128
  }) : [];
77
129
  const numbers = (stackTotals.length ? stackTotals : data.rows.flatMap(row => yEncodings.map(encoding => Number(row[encoding.field])))).filter(Number.isFinite);
78
- const min = Math.min(0, ...(numbers.length ? numbers : [0]));
79
- const max = Math.max(1, ...(numbers.length ? numbers : [1]));
80
- const x = category => temporal ? plot.x + ((new Date(category).getTime() - xMin) / (xMax - xMin || 1)) * plot.width : quantitativeX ? plot.x + ((Number(category) - xMin) / (xMax - xMin || 1)) * plot.width : plot.x + (categories.indexOf(String(category)) + 0.5) * plot.width / Math.max(1, categories.length);
130
+ const rawMin = Math.min(0, ...(numbers.length ? numbers : [0]));
131
+ const rawMax = Math.max(1, ...(numbers.length ? numbers : [1]));
81
132
  const axisType = spec.yAxis?.type || yEncodings[0]?.type || 'linear';
133
+ const [min, max] = resolveAxisDomain(rawMin, rawMax, spec.yAxis || {}, axisType);
134
+ const x = category => temporal ? plot.x + ((new Date(category).getTime() - xMin) / (xMax - xMin || 1)) * plot.width : quantitativeX ? plot.x + ((Number(category) - xMin) / (xMax - xMin || 1)) * plot.width : plot.x + (categories.indexOf(String(category)) + 0.5) * plot.width / Math.max(1, categories.length);
82
135
  const transform = value => axisType === 'log' ? Math.log10(Math.max(0.000001, Number(value))) : Number(value);
83
136
  const transformedMin = transform(min), transformedMax = transform(max);
84
137
  const y = value => plot.y + plot.height - ((transform(value) - transformedMin) / (transformedMax - transformedMin || 1)) * plot.height;
85
138
  const yRightNumbers = data.rows.map(row => Number(row[yEncodings[1]?.field])).filter(Number.isFinite);
86
- const rightMin = Math.min(0, ...(yRightNumbers.length ? yRightNumbers : [0])), rightMax = Math.max(1, ...(yRightNumbers.length ? yRightNumbers : [1]));
139
+ const rawRightMin = Math.min(0, ...(yRightNumbers.length ? yRightNumbers : [0])), rawRightMax = Math.max(1, ...(yRightNumbers.length ? yRightNumbers : [1]));
87
140
  const rightType = spec.yAxis?.right?.type || yEncodings[1]?.type || 'linear';
141
+ const [rightMin, rightMax] = resolveAxisDomain(rawRightMin, rawRightMax, spec.yAxis?.right || {}, rightType);
88
142
  const rightTransform = value => rightType === 'log' ? Math.log10(Math.max(0.000001, Number(value))) : Number(value);
89
143
  const yRight = value => plot.y + plot.height - ((rightTransform(value) - rightTransform(rightMin)) / (rightTransform(rightMax) - rightTransform(rightMin) || 1)) * plot.height;
90
- return { plot, chrome, xLabels, compact: width < 360 || height < 240, recommendedSize: { minWidth: 280, minHeight: 220 }, xField, xEncoding, temporal, quantitativeX, xMin, xMax, yField, yEncodings, categories, min, max, rightMin, rightMax, x, y, yRight, axisType, rightType };
144
+ const yTicks = axisTicks(min, max, spec.yAxis || {}, axisType), rightTicks = axisTicks(rightMin, rightMax, spec.yAxis?.right || {}, rightType);
145
+ 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' });
146
+ 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 };
91
147
  }
92
148
 
93
149
  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 })); }
@@ -97,21 +153,21 @@ function addAxes(scene, spec, state) {
97
153
  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 } }));
98
154
  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 } }));
99
155
  if (spec.type === 'bar') {
100
- const ticks = [min, min + (max - min) / 2, max], numericX = value => plot.x + ((value - min) / (max - min || 1)) * plot.width;
156
+ const ticks = state.yTicks, numericX = value => plot.x + ((value - min) / (max - min || 1)) * plot.width;
101
157
  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 })));
102
- ticks.forEach(value => addText(scene, `label-x-value-${value}`, formatValue(value, spec.xAxis?.format || spec.yAxis?.format), numericX(value), plot.y + plot.height + 22, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'middle' }));
158
+ 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' }));
103
159
  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' }));
104
160
  return;
105
161
  }
106
162
  const labels = state.temporal || state.quantitativeX ? timeTicks(state.xMin, state.xMax, 5) : categories;
107
- const yTicks = [min, min + (max - min) / 2, max];
163
+ const yTicks = state.yTicks;
108
164
  if (spec.grid?.visible !== false) yTicks.forEach((value, index) => scene.add(new SceneNode({ id: `grid-y-${index}`, type: 'line', geometry: { x1: plot.x, y1: y(value), x2: plot.x + plot.width, y2: y(value) }, style: { stroke: spec.grid?.color || spec.theme.grid, strokeWidth: spec.theme.marks.gridWidth }, zIndex: -2 })));
109
165
  labels.forEach((category, index) => {
110
166
  if (index % state.xLabels.step !== 0 && index !== labels.length - 1) return;
111
- addText(scene, `label-x-${category}`, state.temporal ? formatTime(category, state.xMax - state.xMin) : state.quantitativeX ? formatValue(category, spec.xAxis?.format) : category, x(category), plot.y + plot.height + (state.xLabels.rotation ? 10 : 22), { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: state.xLabels.rotation ? 'end' : 'middle', textBaseline: state.xLabels.rotation ? 'middle' : 'alphabetic', rotation: state.xLabels.rotation });
167
+ addText(scene, `label-x-${category}`, state.temporal ? formatTime(category, state.xMax - state.xMin, spec.xAxis?.format, spec.locale) : state.quantitativeX ? formatValue(category, spec.xAxis?.format, spec.locale) : category, x(category), plot.y + plot.height + (state.xLabels.rotation ? 10 : 22), { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: state.xLabels.rotation ? 'end' : 'middle', textBaseline: state.xLabels.rotation ? 'middle' : 'alphabetic', rotation: state.xLabels.rotation });
112
168
  });
113
- yTicks.forEach(value => addText(scene, `label-y-${value}`, formatValue(value, spec.yAxis?.format), plot.x - 10, y(value) + 4, { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: 'end' }));
114
- if (state.yEncodings.length > 1 && !spec.stack) [state.rightMin, (state.rightMin + state.rightMax) / 2, state.rightMax].forEach(value => addText(scene, `label-y-right-${value}`, formatValue(value, spec.yAxis?.right?.format), plot.x + plot.width + 10, yRight(value) + 4, { fill: spec.theme.muted, font: font(spec, 'axis') }));
169
+ 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' }));
170
+ 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') }));
115
171
  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' });
116
172
  if (spec.yAxis?.title) addText(scene, 'axis-y-title', spec.yAxis.title, 8, plot.y + 12, { fill: spec.theme.text, font: font(spec, 'axis') });
117
173
  }
@@ -134,9 +190,9 @@ function legendEntries(spec, data, series, colors) {
134
190
  return [];
135
191
  }
136
192
 
137
- function addMarkLabel(scene, spec, id, value, x, y, dataRef, bounds = null, anchor = null) {
193
+ function addMarkLabel(scene, spec, id, value, x, y, dataRef, bounds = null, anchor = null, formatOverride = null) {
138
194
  if (!spec.labels?.enabled) return;
139
- const text = formatValue(value, spec.labels.format), size = fontSize(spec, 'label', 12), width = estimateTextWidth(text, size), height = size * 1.25;
195
+ const text = formatValue(value, formatOverride || spec.labels.format, spec.locale), size = fontSize(spec, 'label', 12), width = estimateTextWidth(text, size), height = size * 1.25;
140
196
  const area = bounds || scene._labelArea || { x: 0, y: 0, width: spec.width, height: spec.height };
141
197
  const safeX = Math.max(area.x + width / 2, Math.min(area.x + area.width - width / 2, x));
142
198
  const safeY = Math.max(area.y + height / 2, Math.min(area.y + area.height - height / 2, y));
@@ -166,7 +222,10 @@ function addHeatmapScene(scene, spec, data, state, colors) {
166
222
  xValues.forEach((value, index) => { if (index % state.xLabels.step !== 0 && index !== xValues.length - 1) return; addText(scene, `heatmap-x-${index}`, value, state.plot.x + (index + .5) * width, state.plot.y + state.plot.height + (state.xLabels.rotation ? 10 : 20), { fill: spec.theme.muted, font: font(spec, 'axis'), textAnchor: state.xLabels.rotation ? 'end' : 'middle', textBaseline: state.xLabels.rotation ? 'middle' : 'alphabetic', rotation: state.xLabels.rotation }); });
167
223
  const yStep = Math.max(1, Math.ceil(fontSize(spec, 'axis', 12) * 1.35 / Math.max(1, height)));
168
224
  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' }); });
169
- data.rows.forEach((row, index) => { const xIndex = xValues.indexOf(String(row[xField])), yIndex = yValues.indexOf(String(row[yField])), value = numericValue(row), 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, (value - min) / (max - min || 1)) : 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 })); });
225
+ 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; } });
227
+ state.labelLayout = { ...(state.labelLayout || {}), heatmap: { hidden: hiddenLabels, visible: visibleLabels } };
228
+ 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.' });
170
229
  state.matrix = { xValues, yValues, min, max };
171
230
  }
172
231
 
@@ -206,16 +265,16 @@ function addRadarScene(scene, spec, data, state, colors) {
206
265
  }
207
266
 
208
267
  function timeTicks(min, max, count) { return Array.from({ length: count }, (_, index) => min + (max - min) * index / Math.max(1, count - 1)); }
209
- function formatTime(value, span) { const date = new Date(value); if (span > 1000 * 86400000 * 365) return String(date.getUTCFullYear()); if (span > 1000 * 86400000 * 60) return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`; return `${date.getUTCMonth() + 1}/${date.getUTCDate()}`; }
268
+ function formatTime(value, span, format, locale = 'en-US') { const date = new Date(value); if (format) { const options = typeof format === 'string' ? { style: format } : format; return formatValue(value, options?.style || options?.options ? { style: options.style || 'date', ...options } : { style: 'date', options }, locale); } const days = span / 86400000, nowYear = date.getUTCFullYear(), startYear = new Date(value - span).getUTCFullYear(); if (days > 365 || (nowYear !== startYear && days > 45)) return String(nowYear); if (days > 60) return `${nowYear}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`; return `${date.getUTCMonth() + 1}/${date.getUTCDate()}${nowYear !== startYear ? `/${nowYear}` : ''}`; }
210
269
 
211
270
  function projectRows(spec, data) { return data.rows.length ? data.rows : (spec.tasks || spec.events || []); }
212
271
  function projectDate(value, min, max, plot) { const time = new Date(value).getTime(); return plot.x + ((time - min) / (max - min || 1)) * plot.width; }
213
- function addProjectAxes(scene, plot, min, max) { scene.add(new SceneNode({ id: 'project-axis', type: 'line', geometry: { x1: plot.x, y1: plot.y + plot.height, x2: plot.x + plot.width, y2: plot.y + plot.height }, style: { stroke: '#94a3b8' } })); timeTicks(min, max, 5).forEach(value => addText(scene, `project-label-${value}`, formatTime(value, max - min), projectDate(value, min, max, plot), plot.y + plot.height + 22, { fill: '#475569', font: '12px system-ui', textAnchor: 'middle' })); }
272
+ function addProjectAxes(scene, plot, min, max, spec = {}) { scene.add(new SceneNode({ id: 'project-axis', type: 'line', geometry: { x1: plot.x, y1: plot.y + plot.height, x2: plot.x + plot.width, y2: plot.y + plot.height }, style: { stroke: '#94a3b8' } })); timeTicks(min, max, 5).forEach(value => addText(scene, `project-label-${value}`, formatTime(value, max - min, spec.xAxis?.format, spec.locale), projectDate(value, min, max, plot), plot.y + plot.height + 22, { fill: '#475569', font: '12px system-ui', textAnchor: 'middle' })); }
214
273
  function addProjectScene(scene, spec, data, state, colors) {
215
274
  const { plot } = state;
216
275
  if (['gantt', 'timeline', 'milestone'].includes(spec.type)) {
217
276
  const rows = projectRows(spec, data), dates = rows.flatMap(row => [new Date(row.start || row.date || row.end).getTime(), new Date(row.end || row.date || row.start).getTime()]).filter(Number.isFinite), min = Math.min(...(dates.length ? dates : [Date.now()])), max = Math.max(...(dates.length ? dates : [min + 86400000]));
218
- addProjectAxes(scene, plot, min, max);
277
+ addProjectAxes(scene, plot, min, max, spec);
219
278
  const positions = new Map(); rows.forEach((row, index) => { const y = plot.y + (index + 0.5) * plot.height / Math.max(1, rows.length), start = projectDate(row.start || row.date || row.end, min, max, plot), end = projectDate(row.end || row.date || row.start, min, max, plot), x = Math.min(start, end), width = Math.max(8, Math.abs(end - start)); positions.set(row.id, { x, y, end, row }); const milestone = spec.type === 'milestone' || row.milestone || start === end; const geometry = milestone ? { cx: start, cy: y, r: 7 } : { x, y: y - 10, width, height: 20 }; scene.add(new SceneNode({ id: `project-item-${index}`, type: milestone ? 'circle' : 'rect', geometry, bounds: milestone ? { x: start - 10, y: y - 10, width: 20, height: 20 } : geometry, style: { fill: colors[index % colors.length], stroke: '#ffffff', strokeWidth: 1 }, dataRef: { dataIndex: index, taskId: row.id, field: 'project' }, interactive: true })); addText(scene, `project-label-${index}`, row.name || row.title || row.label || row.id || `Item ${index + 1}`, plot.x - 8, y + 4, { fill: '#334155', font: '12px system-ui', textAnchor: 'end' }); if (row.progress != null && !milestone) scene.add(new SceneNode({ id: `project-progress-${index}`, type: 'rect', geometry: { x, y: y - 10, width: width * Math.max(0, Math.min(1, Number(row.progress) > 1 ? Number(row.progress) / 100 : Number(row.progress))), height: 20 }, style: { fill: '#0f172a', opacity: 0.25 }, interactive: false })); });
220
279
  const criticalPath = new Set(spec.criticalPath || []);
221
280
  rows.forEach((row, index) => (row.dependencies || []).forEach((dependency, dependencyIndex) => { const from = positions.get(dependency), to = positions.get(row.id); if (!from || !to) return; const critical = criticalPath.has(row.id) && criticalPath.has(dependency); const angle = Math.atan2(to.y - from.y, to.x - from.end), arrow = 7; scene.add(new SceneNode({ id: `dependency-${index}-${dependencyIndex}`, type: 'line', geometry: { x1: from.end, y1: from.y, x2: to.x, y2: to.y }, style: { stroke: critical ? '#dc2626' : '#64748b', strokeWidth: critical ? 2.5 : 1.5, opacity: 0.85 }, dataRef: { from: dependency, to: row.id, critical }, interactive: false, zIndex: -1 })); scene.add(new SceneNode({ id: `dependency-arrow-${index}-${dependencyIndex}`, type: 'path', geometry: { points: [{ x: to.x, y: to.y }, { x: to.x - arrow * Math.cos(angle - Math.PI / 6), y: to.y - arrow * Math.sin(angle - Math.PI / 6) }, { x: to.x - arrow * Math.cos(angle + Math.PI / 6), y: to.y - arrow * Math.sin(angle + Math.PI / 6) }] }, style: { stroke: critical ? '#dc2626' : '#64748b', fill: critical ? '#dc2626' : '#64748b', strokeWidth: 1 }, interactive: false, zIndex: -1 })); }));
@@ -303,13 +362,15 @@ export function buildScene(spec) {
303
362
  } else if (spec.type === 'pie') {
304
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;
305
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' }); }
306
- 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); angle = end; });
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; });
307
366
  } else if (spec.type === 'funnel') {
308
367
  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;
309
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); });
310
369
  } else if (spec.type === 'gauge') {
311
- const valueField = spec.encoding.value?.field || 'value', domain = spec.domain || [0, 100], rawValue = Number(data.rows[0]?.[valueField]) || 0, value = Math.max(Number(domain[0]), Math.min(Number(domain[1]), rawValue)), 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;
312
- scene.add(new SceneNode({ id: 'gauge-background', type: 'arc', geometry: { cx, cy, r: radius, start: Math.PI, end: Math.PI * 2 }, style: { fill: spec.theme.grid } })); scene.add(new SceneNode({ id: 'gauge-value', type: 'arc', geometry: { cx, cy, r: radius, start, end }, style: { fill: colors[0] }, dataRef: { seriesIndex: 0, dataIndex: 0, value }, interactive: true })); addText(scene, 'gauge-label', formatValue(value, spec.labels?.format || spec.encoding.value?.format || { style: 'percent', ratio: false }), cx, cy - 12, { fill: spec.theme.text, font: font(spec, 'metric'), textAnchor: 'middle' });
370
+ 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
+ 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.' });
372
+ if (numericValue < Number(domain[0]) || numericValue > Number(domain[1])) data.warnings.push({ code: 'VALUE_CLAMPED', path: `data.values[0].${valueField}`, count: 1, rawValue: numericValue, domain: [...domain], message: `Gauge value ${numericValue} is outside domain [${domain[0]}, ${domain[1]}] and was clamped for the arc.`, suggestion: 'Choose a domain that covers the value or review the source unit.' });
373
+ scene.add(new SceneNode({ id: 'gauge-background', type: 'arc', geometry: { cx, cy, r: radius, start: Math.PI, end: Math.PI * 2 }, style: { fill: spec.theme.grid } })); scene.add(new SceneNode({ id: 'gauge-value', type: 'arc', geometry: { cx, cy, r: radius, start, end }, style: { fill: colors[0] }, dataRef: { seriesIndex: 0, dataIndex: 0, value, rawValue: numericValue }, interactive: true })); addText(scene, 'gauge-label', formatValue(numericValue, spec.labels?.format || spec.encoding.value?.format || { maximumFractionDigits: 2 }, spec.locale), cx, cy - 12, { fill: spec.theme.text, font: font(spec, 'metric'), textAnchor: 'middle' });
313
374
  }
314
375
  addLegend(scene, spec, state.chrome.legend);
315
376
  addBranding(scene, spec);
package/src/index.mjs CHANGED
@@ -344,13 +344,21 @@ 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
- getState() { const brandingSignature = discoverCapabilities().branding.signature; 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 }), preferences: this.getPreferences(), branding: { enabled: Boolean(this.spec.branding?.enabled), signature: brandingSignature, text: this.spec.branding?.enabled === true ? brandingSignature : null }, warnings: clone([...(this._specDiagnostics?.warnings || []), ...(this.model.data?.warnings || []), ...(this.spec.theme?.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) }; }
347
+ _getDiagnostics() { return [...(this._specDiagnostics?.warnings || []), ...(this.model.data?.warnings || []), ...(this.spec.theme?.warnings || [])]; }
348
+ _getHealth(warnings) {
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
+ const marks = [];
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 } };
354
+ }
355
+ 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) }; }
348
356
  getProjectAnalytics() { return clone(this.model.state?.projectAnalytics || null); }
349
357
  getLinkedState() { return clone(this.model.state?.linked || null); }
350
358
  setLinkedFilters(filters = {}) { this.spec.project = { ...(this.spec.project || {}), linked: { ...(this.spec.project?.linked || {}), filters: normalizeLinkedFilters(filters) } }; this.emit('linkedstatechange', { chart: this, linked: this.spec.project.linked }); return this.render(); }
351
359
  setLinkedSelection(selection = []) { this.spec.project = { ...(this.spec.project || {}), linked: { ...(this.spec.project?.linked || {}), selection: normalizeLinkedSelection(selection) } }; this.emit('linkedstatechange', { chart: this, linked: this.spec.project.linked }); return this.render(); }
352
360
  describe() { return { type: this.spec.type, renderer: this.renderer.constructor.name, dimensions: [this.spec.encoding.x?.field || this.spec.encoding.category?.field], measures: (Array.isArray(this.spec.encoding.y) ? this.spec.encoding.y : [this.spec.encoding.y || this.spec.encoding.value]).filter(Boolean).map(encoding => encoding.field), dataCount: this.model.data.rows.length, theme: this.spec.theme?.name || 'custom', interactions: Object.keys(this.spec.interaction || {}).filter(key => this.spec.interaction[key]) }; }
353
- explain() { return explainChart(this.spec, this.model); }
361
+ explain() { const explanation = explainChart(this.spec, this.model), state = this.getState(); return { ...explanation, warnings: state.warnings, health: state.health }; }
354
362
  getAccessibleDescription() { const description = this.spec.accessibility?.description || this.spec.title?.text || `${this.spec.type} chart`; return `${description}; ${this.model.data.rows.length} data items.`; }
355
363
  inspectDataSchema() { return inspectDataSchema(this.spec.data.schema || this.spec.schema); }
356
364
  validateData() { return validateData(this.toDataTable(), this.spec.data.schema || this.spec.schema, this.spec.validationOptions); }
@@ -630,4 +638,4 @@ export { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, cr
630
638
 
631
639
  export { contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin };
632
640
  export { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, mountChartSettings, normalizePreferences, validatePreferences };
633
- export const iChart = { version: '2.0.7', 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 };
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 };
package/src/spec.mjs CHANGED
@@ -23,7 +23,8 @@ const defaults = {
23
23
  labels: { enabled: false },
24
24
  branding: { enabled: true },
25
25
  interaction: { tooltip: true, hover: true, click: true, crosshair: false, zoom: false, pan: false, brush: false, drag: false, edgeDrag: false, portConnect: false, keyboard: true }
26
- ,responsive: [], accessibility: { enabled: false }, editing: { enabled: false, mode: 'command', requireConfirmation: true, allowDelete: false, allowStructuralChanges: false }, xAxis: {}, yAxis: {}
26
+ ,responsive: [], accessibility: { enabled: false }, editing: { enabled: false, mode: 'command', requireConfirmation: true, allowDelete: false, allowStructuralChanges: false }, xAxis: {}, yAxis: { nice: true, ticks: 'auto' }
27
+ ,locale: 'en-US'
27
28
  };
28
29
 
29
30
  function clone(value) {
@@ -59,6 +60,41 @@ function dependencyErrors(rows) {
59
60
  return errors;
60
61
  }
61
62
 
63
+ const encodingChannels = {
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: [],
66
+ gantt: [], timeline: [], milestone: [], burndown: [], flow: [], swimlane: [], architecture: [], mindmap: []
67
+ };
68
+
69
+ const presentationEncodingKeys = new Set(['title', 'format', 'labels', 'legend']);
70
+
71
+ function finiteDomain(domain) {
72
+ return Array.isArray(domain) && domain.length === 2 && domain.every(value => Number.isFinite(Number(value))) && Number(domain[1]) > Number(domain[0]);
73
+ }
74
+
75
+ function validateEncodingContract(input, spec, errors) {
76
+ const rows = Array.isArray(spec.data?.values) ? spec.data.values : [], fields = new Set(rows.flatMap(row => Object.keys(row || {})));
77
+ if (!rows.length) return;
78
+ const rawEncoding = input.encoding && typeof input.encoding === 'object' ? input.encoding : {};
79
+ const allowed = new Set(encodingChannels[spec.type] || []);
80
+ Object.keys(rawEncoding).forEach(channel => {
81
+ if (presentationEncodingKeys.has(channel) || allowed.has(channel)) return;
82
+ errors.push({ code: 'UNSUPPORTED_ENCODING_CHANNEL', path: `encoding.${channel}`, message: `${spec.type} does not use encoding.${channel}.`, expected: [...allowed], suggestion: allowed.size ? `Use encoding.${[...allowed].join(' or encoding.')} for ${spec.type}.` : 'Remove field encodings from this chart type.' });
83
+ });
84
+ const check = (path, encoding) => {
85
+ const items = Array.isArray(encoding) ? encoding : [encoding];
86
+ items.forEach((item, index) => {
87
+ if (!item) return;
88
+ const field = item.field, itemPath = Array.isArray(encoding) ? `${path}.${index}.field` : `${path}.field`;
89
+ if (typeof field !== 'string' || !field) errors.push({ code: 'INVALID_ENCODING_FIELD', path: itemPath, message: 'Encoding fields must be non-empty strings.', suggestion: 'Use a field name that exists in data.values.' });
90
+ 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
+ });
92
+ };
93
+ if (['pie', 'funnel', 'gauge'].includes(spec.type) && (rawEncoding.x !== undefined || rawEncoding.y !== undefined)) return;
94
+ (encodingChannels[spec.type] || []).forEach(channel => check(`encoding.${channel}`, spec.encoding?.[channel]));
95
+ if (spec.type === 'radar') (spec.indicators || []).forEach((indicator, index) => check(`indicators.${index}`, indicator));
96
+ }
97
+
62
98
  export function normalizeSpec(input = {}) {
63
99
  const spec = merge(defaults, input);
64
100
  if (input.branding === undefined && input.theme && typeof input.theme === 'object' && input.theme.branding !== undefined) spec.branding = clone(input.theme.branding);
@@ -84,7 +120,7 @@ export function normalizeSpec(input = {}) {
84
120
  return spec;
85
121
  }
86
122
 
87
- export function validateSpec(input) {
123
+ export function validateSpec(input = {}) {
88
124
  const spec = normalizeSpec(input);
89
125
  const errors = [], warnings = [], normalizations = [];
90
126
  if (!chartTypes.has(spec.type)) errors.push({ code: 'INVALID_TYPE', path: 'type', message: `Unsupported chart type: ${spec.type}`, suggestion: 'Use a type returned by getCapabilities().' });
@@ -97,10 +133,44 @@ export function validateSpec(input) {
97
133
  if (spec.theme.branding != null && typeof spec.theme.branding !== 'boolean' && !(spec.theme.branding && typeof spec.theme.branding === 'object')) errors.push({ code: 'INVALID_BRANDING', path: 'theme.branding', message: 'theme.branding must be a boolean or a { enabled: boolean } object.', suggestion: 'Use theme: { branding: false } or theme: { branding: { enabled: false } }.' });
98
134
  else if (spec.theme.branding && typeof spec.theme.branding === 'object' && (Object.keys(spec.theme.branding).some(key => key !== 'enabled') || typeof spec.theme.branding.enabled !== 'boolean')) errors.push({ code: 'INVALID_BRANDING', path: 'theme.branding', message: 'theme.branding objects only support a boolean enabled property.', suggestion: 'Use theme: { branding: { enabled: false } }.' });
99
135
  }
136
+ if (typeof spec.locale !== 'string' || !spec.locale.trim()) errors.push({ code: 'INVALID_LOCALE', path: 'locale', message: 'locale must be a non-empty BCP 47 locale string.', suggestion: 'Use for example locale: "en-US" or locale: "zh-CN".' });
137
+ validateEncodingContract(input, spec, errors);
138
+ const axisEncodings = [['x', 'xAxis'], ['y', 'yAxis']];
139
+ axisEncodings.forEach(([encodingName, axisName]) => {
140
+ const encoding = spec.encoding?.[encodingName];
141
+ const encodingItems = Array.isArray(encoding) ? encoding : [encoding];
142
+ encodingItems.forEach((item, index) => {
143
+ if (!item || typeof item !== 'object') return;
144
+ const pathPrefix = Array.isArray(encoding) ? `encoding.${encodingName}.${index}` : `encoding.${encodingName}`;
145
+ if (item.title !== undefined) warnings.push({ code: 'MISPLACED_AXIS_TITLE', path: `${pathPrefix}.title`, message: `Axis titles belong on ${axisName}.title, not on an encoding.`, suggestion: `Move it to ${axisName}: { title: ... }.` });
146
+ if (item.format !== undefined) warnings.push({ code: 'MISPLACED_AXIS_FORMAT', path: `${pathPrefix}.format`, message: `Axis formats belong on ${axisName}.format, not on an encoding.`, suggestion: `Move it to ${axisName}: { format: ... }.` });
147
+ });
148
+ });
149
+ if (spec.encoding?.labels !== undefined) warnings.push({ code: 'MISPLACED_LABELS', path: 'encoding.labels', message: 'Data labels are a chart-level option.', suggestion: 'Move it to labels: { enabled: true, ... }.' });
150
+ if (spec.encoding?.legend !== undefined) warnings.push({ code: 'MISPLACED_LEGEND', path: 'encoding.legend', message: 'Legend visibility is a chart-level option.', suggestion: 'Move it to legend: { visible: false }.' });
151
+ [['xAxis', spec.xAxis], ['yAxis', spec.yAxis]].forEach(([axisName, axis]) => {
152
+ ['min', 'max'].forEach(bound => {
153
+ if (axis?.[bound] !== undefined || axis?.right?.[bound] !== undefined) warnings.push({ code: 'UNSUPPORTED_AXIS_DOMAIN', path: `${axisName}.${bound}`, message: 'Use axis.domain rather than axis.min/axis.max for an explicit numeric domain.', suggestion: 'Use yAxis: { domain: [0, 2000] } or yAxis: { nice: true }.' });
154
+ });
155
+ if (axisName === 'xAxis' && axis?.domain !== undefined) warnings.push({ code: 'UNSUPPORTED_AXIS_DOMAIN', path: 'xAxis.domain', message: 'Explicit x-axis domain is not supported by the current categorical/time layout.', suggestion: 'Use yAxis.domain for numeric value axes; x-axis ranges are derived from records.' });
156
+ if (axisName === 'xAxis' && (axis?.nice !== undefined || axis?.ticks !== undefined)) warnings.push({ code: 'UNSUPPORTED_AXIS_TICKS', path: 'xAxis', message: 'Nice domains and tick counts currently apply to yAxis, not categorical/time x-axis layouts.', suggestion: 'Move numeric scale controls to yAxis.' });
157
+ if (axisName === 'xAxis') return;
158
+ [axis, axis?.right].forEach((axisConfig, index) => {
159
+ if (!axisConfig) return;
160
+ const pathPrefix = index ? `${axisName}.right` : axisName;
161
+ if (axisConfig.domain !== undefined && !(Array.isArray(axisConfig.domain) && axisConfig.domain.length === 2 && axisConfig.domain.every(value => Number.isFinite(Number(value))) && Number(axisConfig.domain[1]) > Number(axisConfig.domain[0]))) errors.push({ code: 'INVALID_AXIS_DOMAIN', path: `${pathPrefix}.domain`, message: 'Axis domain must be [min, max] with two finite numbers and max greater than min.', suggestion: 'Use for example yAxis: { domain: [0, 2000] }.' });
162
+ if (axisConfig.nice !== undefined && typeof axisConfig.nice !== 'boolean') errors.push({ code: 'INVALID_AXIS_NICE', path: `${pathPrefix}.nice`, message: 'Axis nice must be a boolean.', suggestion: 'Use nice: true or nice: false.' });
163
+ if (axisConfig.ticks !== undefined && axisConfig.ticks !== 'auto' && !(Number.isInteger(axisConfig.ticks) && axisConfig.ticks >= 2)) errors.push({ code: 'INVALID_AXIS_TICKS', path: `${pathPrefix}.ticks`, message: 'Axis ticks must be auto or an integer of at least 2 labels.', suggestion: 'Use ticks: "auto" or ticks: 5.' });
164
+ });
165
+ });
100
166
  if (Array.isArray(spec.encoding.y) && spec.encoding.y.length > 2) errors.push({ code: 'TOO_MANY_AXES', path: 'encoding.y', message: 'Only two quantitative axes are supported in this iteration.', suggestion: 'Use at most two y encodings.' });
101
167
  if (spec.stack && !['bar', 'column', 'area'].includes(spec.type)) errors.push({ code: 'INVALID_STACK', path: 'stack', message: 'Stacking is supported by bar, column, and area charts.', suggestion: 'Remove stack or use a supported chart type.' });
102
168
  if (spec.stack && !['stacked', 'percent'].includes(typeof spec.stack === 'string' ? spec.stack : spec.stack.mode)) errors.push({ code: 'INVALID_STACK_MODE', path: 'stack', message: 'Stack mode must be stacked or percent.', suggestion: 'Use stack: "stacked" or stack: "percent".' });
103
169
  if (spec.type === 'pie' && spec.innerRadius != null && (!(Number(spec.innerRadius) >= 0) || Number(spec.innerRadius) >= 1)) errors.push({ code: 'INVALID_INNER_RADIUS', path: 'innerRadius', message: 'Pie innerRadius must be a ratio from 0 up to, but not including, 1.', suggestion: 'Use a value such as 0.55.' });
170
+ if (spec.type === 'gauge') {
171
+ if (spec.domain === undefined) errors.push({ code: 'MISSING_GAUGE_DOMAIN', path: 'domain', message: 'Gauge requires an explicit numeric domain to avoid silently clipping values.', suggestion: 'Use domain: [0, 100] for a percentage KPI or declare the business range.' });
172
+ 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
+ }
104
174
  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.' });
105
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.' });
106
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.' });
@@ -117,6 +187,12 @@ export function validateSpec(input) {
117
187
  if (spec.type === 'gantt') errors.push(...dependencyErrors(spec.data.values));
118
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.' });
119
189
  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
+ const profile = chartProfiles[spec.type], inputOptions = input && typeof input === 'object' ? input : {};
191
+ [['legend', 'legend'], ['grid', 'grid'], ['labels', 'labels']].forEach(([option, feature]) => {
192
+ 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
+ });
194
+ 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 }].' });
120
196
  const supportedInteractions = chartProfiles[spec.type]?.interactions || [];
121
197
  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.' }); });
122
198
  if (spec.branding != null && typeof spec.branding !== 'boolean' && !(spec.branding && typeof spec.branding === 'object')) {
package/types/index.d.ts CHANGED
@@ -24,12 +24,15 @@ export interface Diagnostic { code: string; path?: string; message: string; expe
24
24
  export interface DataFieldInfo { name: string; type: 'quantitative' | 'temporal' | 'category' | 'unknown'; role: 'identifier' | 'measure' | 'temporal-dimension' | 'dimension'; unit: string | null; cardinality: number; validCount: number; nullCount: number; min?: number; max?: number; temporalMin?: string; temporalMax?: string; }
25
25
  export interface DataInspection { version: '1.0'; rows: number; fields: DataFieldInfo[]; dimensions: string[]; measures: string[]; temporalFields: string[]; missingValueCount: number; warnings: Diagnostic[]; }
26
26
  export interface ChartCapability { type: ChartType; family: string; intents: string[]; required: string[]; optional: string[]; dataShapes: string[]; interactions: string[]; features: Record<string, 'supported' | 'not-applicable' | 'degraded'>; renderers: Array<'canvas' | 'svg'>; exports: string[]; limits: Record<string, number>; }
27
- export interface RuntimeCapabilities { version: '2.0'; contractVersion: '1.0'; chartTypes: ChartType[]; charts: Record<ChartType, ChartCapability>; intents: string[]; renderers: Array<'canvas' | 'svg'>; interactions: string[]; exports: Array<'png' | 'svg' | 'json' | 'jpeg'>; styleSystem: { modes: ThemeMode[]; presets: ThemePreset[]; palettes: ThemePalette[]; switchable: boolean; automatic: boolean; [key: string]: unknown }; preferences?: { version: '1.0'; scopes: string[]; persistence: string[]; fields: string[]; agentAdjustable: boolean; interactiveSettingsUI: boolean; precedence: string[]; schema: PreferenceCapabilities }; headless: { preview?: boolean; json: boolean; svg: boolean; png: boolean | string }; export: { types: string[]; mime: Record<string, string>; browser: Record<string, boolean>; headless: Record<string, string | boolean>; methods: string[]; options: Record<string, unknown>; branding: Record<string, unknown> }; branding: { defaultEnabled: boolean; signature: string; options: Record<string, unknown> }; [key: string]: unknown; }
28
- export interface ChartPlan { version: '1.0'; intent: string; primary: ChartType; alternatives: ChartType[]; confidence: number; reasons: string[]; requiredFields: string[]; suggestedEncodings: { dimension: string | null; measure: string | null; secondaryMeasure: string | null }; assumptions: string[]; warnings: Diagnostic[]; unsupportedRequests: string[]; nextActions: string[]; capability: ChartCapability; styleRecommendation: StyleRecommendation; data: DataInspection; }
29
- export interface ChartExplanation { version: '1.0'; type: ChartType; family: string; purpose: string; renderer: Renderer; dataCount: number; encodings: Record<string, string | string[]>; transforms: string[]; interactions: string[]; assumptions: string[]; warnings: Diagnostic[]; style: Partial<StyleRecommendation> & { name?: string }; lineage: { recordIds: string[]; sourcePreserved: boolean }; accessibility: { enabled: boolean; summary: string }; }
27
+ export interface RuntimeCapabilities { version: '2.0'; contractVersion: '1.0'; chartTypes: ChartType[]; charts: Record<ChartType, ChartCapability>; intents: string[]; locale?: { default: string; recommended: string[]; appliesTo: string[]; inputDates: string }; renderers: Array<'canvas' | 'svg'>; interactions: string[]; exports: Array<'png' | 'svg' | 'json' | 'jpeg'>; styleSystem: { modes: ThemeMode[]; presets: ThemePreset[]; palettes: ThemePalette[]; switchable: boolean; automatic: boolean; [key: string]: unknown }; preferences?: { version: '1.0'; scopes: string[]; persistence: string[]; fields: string[]; agentAdjustable: boolean; interactiveSettingsUI: boolean; precedence: string[]; schema: PreferenceCapabilities }; headless: { preview?: boolean; json: boolean; svg: boolean; png: boolean | string }; export: { types: string[]; mime: Record<string, string>; browser: Record<string, boolean>; headless: Record<string, string | boolean>; methods: string[]; options: Record<string, unknown>; branding: Record<string, unknown> }; branding: { defaultEnabled: boolean; signature: string; options: Record<string, unknown> }; [key: string]: unknown; }
28
+ export interface ChartPlan { version: '1.0'; intent: string; intentKnown?: boolean; intentSuggestions?: string[]; fallbackUsed?: boolean; primary: ChartType; alternatives: ChartType[]; confidence: number; reasons: string[]; requiredFields: string[]; suggestedEncodings: { dimension: string | null; measure: string | null; secondaryMeasure: string | null }; assumptions: string[]; warnings: Diagnostic[]; unsupportedRequests: string[]; nextActions: string[]; capability: ChartCapability; styleRecommendation: StyleRecommendation; data: DataInspection; }
29
+ export interface AxisState { rawDomain: [number, number]; domain: [number, number]; ticks: number[]; step: number | null; policy: 'nice' | 'raw' | 'explicit'; }
30
+ export interface ChartHealth { version: '1.0'; status: 'ready' | 'degraded' | 'empty'; renderable: boolean; issues: string[]; metrics: { warnings: number; suppressedLabels: number; clampedValues: number; renderedMarks: number }; }
31
+ export interface ChartExplanation { version: '1.0'; type: ChartType; family: string; purpose: string; renderer: Renderer; dataCount: number; encodings: Record<string, string | string[]>; transforms: string[]; interactions: string[]; assumptions: string[]; warnings: Diagnostic[]; axes?: { y?: AxisState; right?: AxisState } | null; health?: ChartHealth; style: Partial<StyleRecommendation> & { name?: string }; lineage: { recordIds: string[]; sourcePreserved: boolean }; accessibility: { enabled: boolean; summary: string }; }
30
32
  export interface ChartInteraction { tooltip?: boolean; hover?: boolean; click?: boolean; crosshair?: boolean; zoom?: boolean; pan?: boolean; brush?: boolean; drag?: boolean; edgeDrag?: boolean; portConnect?: boolean; keyboard?: boolean; [key: string]: boolean | undefined; }
31
33
  export interface ChartEditing { enabled?: boolean; mode?: 'command' | string; requireConfirmation?: boolean; allowDelete?: boolean; allowStructuralChanges?: boolean; }
32
- export interface ChartSpec { type: ChartType; renderer?: Renderer; container?: string | Element; chartId?: string; width?: number; height?: number; data?: Array<Record<string, unknown>> | { values?: Array<Record<string, unknown>>; [key: string]: unknown }; encoding?: Record<string, unknown>; title?: { text?: string; subtitle?: string }; legend?: { visible?: boolean; position?: string }; grid?: { visible?: boolean; color?: string }; labels?: { enabled?: boolean; format?: string | Record<string, unknown>; color?: string; font?: string }; diagram?: DiagramConfig; interaction?: ChartInteraction; editing?: ChartEditing; accessibility?: { enabled?: boolean; description?: string }; branding?: boolean | { enabled?: boolean }; theme?: ThemeMode | ThemePreset | ThemeConfig | ResolvedTheme; preferences?: ChartPreferencesPatch | PreferencesStore; preferencesStore?: PreferencesStore; [key: string]: unknown; }
34
+ export interface AxisSpec { type?: 'linear' | 'log' | 'quantitative' | 'temporal' | 'time' | string; title?: string; format?: string | Record<string, unknown>; domain?: [number, number]; nice?: boolean; ticks?: 'auto' | number; right?: AxisSpec; [key: string]: unknown; }
35
+ export interface ChartSpec { type: ChartType; renderer?: Renderer; container?: string | Element; chartId?: string; width?: number; height?: number; locale?: string; data?: Array<Record<string, unknown>> | { values?: Array<Record<string, unknown>>; [key: string]: unknown }; encoding?: Record<string, unknown>; title?: { text?: string; subtitle?: string }; legend?: { visible?: boolean; position?: string }; grid?: { visible?: boolean; color?: string }; labels?: { enabled?: boolean; format?: string | Record<string, unknown>; color?: string; font?: string }; xAxis?: AxisSpec; yAxis?: AxisSpec; diagram?: DiagramConfig; interaction?: ChartInteraction; editing?: ChartEditing; accessibility?: { enabled?: boolean; description?: string }; branding?: boolean | { enabled?: boolean }; theme?: ThemeMode | ThemePreset | ThemeConfig | ResolvedTheme; preferences?: ChartPreferencesPatch | PreferencesStore; preferencesStore?: PreferencesStore; [key: string]: unknown; }
33
36
  export interface BinTransform { type: 'bin'; field: string; output?: string; thresholds?: number; step?: number; extent?: [number, number]; }
34
37
  export interface RadarIndicator { name: string; field: string; min?: number; max?: number; }
35
38
  export interface DiagramNode { id: string; label: string; position?: { x: number; y: number }; size?: { width: number; height: number }; ports?: Array<{ id: string; side?: 'left' | 'right' | 'top' | 'bottom'; offset?: number }>; groupId?: string; }
@@ -90,7 +93,7 @@ export interface Chart {
90
93
  previewEdit(command: EditCommand): EditPreview;
91
94
  applyEdit(command: EditCommand, options?: { preview?: EditPreview; confirmed?: boolean; approval?: unknown; previewId?: string; expectedRevision?: number; actor?: string; source?: string; reason?: string }): Record<string, unknown>;
92
95
  getChangeSet(): Record<string, unknown> | null;
93
- getState(): Record<string, unknown>;
96
+ getState(): Record<string, unknown> & { axes?: { y?: AxisState; right?: AxisState } | null; health?: ChartHealth; warnings?: Diagnostic[] };
94
97
  getProjectAnalytics(): ProjectAnalyticsState | null;
95
98
  getLinkedState(): LinkedState | null;
96
99
  setLinkedFilters(filters: LinkedFilters): this;