@taylorwong/ichartjs 2.0.14 → 2.0.15

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.15 - 2026-09-24
4
+
5
+ - Added the Iteration 13F axis-free layout strategy for Pie, Funnel, Gauge, and Radar, prioritizing chart body area and placing supported legends without Cartesian-axis reserves.
6
+ - Exposed resolved layout geometry through `getState().layout` and `explain().layout`, with focused regression coverage for axis-free charts.
7
+ - Removed archived iChart.js 1.x sources and obsolete 2.0 example snapshots, and added `.trae` to the ignore rules.
8
+ - Synchronized package, runtime, Playground, documentation, and Skill references for `v2.0.15`.
9
+
3
10
  ## 2.0.14 - 2026-09-23
4
11
 
5
12
  - Added a canonical contract registry with synchronized runtime capabilities, offline chart profiles, export declarations, command metadata, business models, and Mindmap edge metadata.
package/README.md CHANGED
@@ -44,7 +44,7 @@ getCapabilities
44
44
  npm install @taylorwong/ichartjs@^2
45
45
  ```
46
46
 
47
- As a fallback for environments without npm access, install directly from GitHub: `npm install github:wanghetommy/ichartjs#v2.0.14`.
47
+ As a fallback for environments without npm access, install directly from GitHub: `npm install github:wanghetommy/ichartjs#v2.0.15`.
48
48
 
49
49
  ### Optional Agent Skill
50
50
 
@@ -66,7 +66,7 @@ For a non-interactive global Codex installation:
66
66
  npx skills add wanghetommy/ichartjs --skill ichartjs --agent codex --global --yes
67
67
  ```
68
68
 
69
- For a release-pinned installation, use `npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.14/skills/ichartjs --agent codex --global --yes`. WorkBuddy users can import the same tagged `skills/ichartjs` URL through the host's Skill interface; do not assume a `--agent workbuddy` adapter unless the installed CLI declares it. Package consumers can still copy `node_modules/@taylorwong/ichartjs/skills/ichartjs` as a manual fallback. After installation, invoke `$ichartjs` when named Skill invocation is supported, or select `ichartjs` in the host UI.
69
+ For a release-pinned installation, use `npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.15/skills/ichartjs --agent codex --global --yes`. WorkBuddy users can import the same tagged `skills/ichartjs` URL through the host's Skill interface; do not assume a `--agent workbuddy` adapter unless the installed CLI declares it. Package consumers can still copy `node_modules/@taylorwong/ichartjs/skills/ichartjs` as a manual fallback. After installation, invoke `$ichartjs` when named Skill invocation is supported, or select `ichartjs` in the host UI.
70
70
 
71
71
  ### Agent workflow
72
72
 
@@ -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.14/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.15/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
 
@@ -40,7 +40,7 @@ iChart.js 2.0 is a new Agent-first product line. Compatibility with 1.x and a 1.
40
40
 
41
41
  ## Iteration 13 Addendum — 2026-09-23
42
42
 
43
- The historical 2.0.0 release decision remains unchanged. Release `v2.0.14` includes Iteration 13 contract hardening without adding a chart type: 18 chart profiles, 25 commands, 11 schemas, contract version `1.1`, atomic mutation validation, Mindmap edge discovery, strict TypeScript consumer compilation, no active source cycles, 96 Node tests, Chromium browser acceptance, and package dry-run verification. It also corrects Bar y-axis title layout and exposes `TITLE_TRUNCATED` when physical chart height forces truncation.
43
+ The historical 2.0.0 release decision remains unchanged. Release `v2.0.15` includes Iteration 13 contract hardening without adding a chart type: 18 chart profiles, 25 commands, 11 schemas, contract version `1.1`, atomic mutation validation, Mindmap edge discovery, strict TypeScript consumer compilation, no active source cycles, 97 Node tests, Chromium browser acceptance, package dry-run verification, and the 13F axis-free layout strategy. It also corrects Bar y-axis title layout and exposes `TITLE_TRUNCATED` when physical chart height forces truncation.
44
44
 
45
45
  ## Deferred npm Publication
46
46
 
@@ -209,6 +209,42 @@ The initial plan is sound, but the following requirements are part of Iteration
209
209
  - `npm run test:browser` covers the maintained critical workflows with no uncaught errors.
210
210
  - `npm pack --dry-run` contains synchronized manifests, types, Agent guides, Skill references, recipes, and examples.
211
211
 
212
+ ## 13F — Axis-Free Chart Layout Strategy
213
+
214
+ 13F adds a dedicated layout strategy for charts without Cartesian axes. It does not add a chart type or change the public Spec shape. The purpose is to keep the visual subject—especially Pie, Gauge, Radar, and Funnel—larger and readable after title, legend, branding, and responsive constraints are applied.
215
+
216
+ ### Scope
217
+
218
+ - Classify `pie`, `funnel`, `gauge`, and `radar` as axis-free layout families instead of applying Cartesian bottom-axis reserves.
219
+ - Keep title, legend, plot body, labels, and branding in separate layout regions.
220
+ - Preserve explicit `padding`; only the theme-derived default bottom reserve is tightened for axis-free charts.
221
+ - Support `legend.position` values `top`, `right`, `bottom`, and `left` in the shared chrome layout.
222
+ - Keep Canvas, SVG, and headless SVG on the same Scene Graph geometry.
223
+
224
+ ### Chart-specific rules
225
+
226
+ - **Pie**: maximize the safe circular body, preserve label contrast and collision checks, and use a larger dynamic radius than the Cartesian-compatible fallback.
227
+ - **Gauge**: fit the upper semicircle from both available width and arc height, keep the metric inside the gauge body, and avoid unused lower whitespace.
228
+ - **Radar**: increase the radial body only when indicator labels remain inside the label area; retain `LABELS_SUPPRESSED` when labels cannot be placed safely.
229
+ - **Funnel**: keep stage rectangles and labels centered while using the full axis-free body region.
230
+
231
+ ### Implementation boundary
232
+
233
+ - Keep the layout-family decision in the shared chart scene builder; do not create a second runtime or renderer-specific layout.
234
+ - Return `layoutFamily` and chrome region geometry in runtime state so Agent inspection can explain the effective layout.
235
+ - Do not expose a large collection of new tuning options. Automatic fitting remains the default; explicit `padding` and `legend.position` remain the host controls.
236
+ - Do not alter Cartesian layout defaults for line, area, bar, column, scatter, or heatmap.
237
+
238
+ ### 13F Checkpoint
239
+
240
+ - Gallery-sized Pie and Radar bodies are materially larger than the previous `0.38 × min(plot)` geometry without label overlap.
241
+ - Gauge uses its available arc area without title, value, or branding collisions.
242
+ - Funnel stages remain centered and readable at desktop and compact sizes.
243
+ - `legend.position` changes the chrome region for axis-free charts and does not overlap the body.
244
+ - Explicit padding remains effective; default axis-free spacing is the only optimized spacing.
245
+ - Canvas, SVG, and headless SVG expose equivalent body geometry and semantic data references.
246
+ - `npm test`, `npm run test:browser`, `npm run agent:check`, and `git diff --check` pass.
247
+
212
248
  ## Execution Order
213
249
 
214
250
  1. Freeze the mutation error contract, public API allowlist, contract version, and stable-ID policy before implementation.
@@ -217,6 +253,7 @@ The initial plan is sound, but the following requirements are part of Iteration
217
253
  4. Complete 13D against the stabilized registry and public API allowlist.
218
254
  5. Execute 13C as small behavior-preserving extractions with parity checks after each move; defer large scene-builder moves if they threaten the release gate.
219
255
  6. Promote 13E checks continuously after each phase, then record final acceptance evidence.
256
+ 7. Execute 13F after the shared contract and mutation boundaries are stable; verify axis-free geometry before release packaging.
220
257
 
221
258
  Each phase must be independently releasable. Do not combine a behavior correction and a large file move in the same change unless tests prove the old and new paths are equivalent.
222
259
 
@@ -241,6 +278,7 @@ Each phase must be independently releasable. Do not combine a behavior correctio
241
278
  | Types | TypeScript fixtures compile for lifecycle, events, editing, diagrams, preferences, and exports. |
242
279
  | Documentation | Canonical examples execute; local links resolve; English/Chinese contract markers match. |
243
280
  | Rendering | Canvas/SVG Scene semantics and headless SVG export remain equivalent. |
281
+ | Axis-free layout | Pie, Gauge, Radar, and Funnel fit their body, chrome, labels, and branding without silent overlap. |
244
282
  | Browser | Generic, project, diagram, preferences, accessibility, and export workflows pass in maintained browser tests. |
245
283
  | Packaging | ESM import, JSON subpaths, recipes, declarations, Skill files, and dry-run tarball checks pass. |
246
284
 
@@ -262,6 +300,7 @@ The implementation may add focused scripts such as `contracts:check`, `types:che
262
300
  - Complete public TypeScript surface and compiled consumer fixtures.
263
301
  - Executable Agent documentation checks and bilingual semantic parity markers.
264
302
  - Complete Architecture/Mindmap Skill routing and diagram-edge capability metadata.
303
+ - Axis-free layout strategy for Pie, Gauge, Radar, and Funnel with shared chrome geometry.
265
304
  - Split unit/contract suites and expanded critical browser workflows.
266
305
  - Updated `roadmap.md`, Agent guides, manifests, Skill references, changelog, and acceptance record.
267
306
 
@@ -271,7 +310,8 @@ The implementation may add focused scripts such as `contracts:check`, `types:che
271
310
  - Mutation safety: `ChartValidationError`, atomic `update()`/`setData()`/`setTheme()`/preference/patch paths, project row diagnostics, duplicate/out-of-order Burndown warnings, and idempotent `destroy()`.
272
311
  - Diagram contract: explicit `mindmap-edge` schema and command discovery aligned with Flow and Architecture edge operations.
273
312
  - Agent/type gates: `npm run types:check` compiles `types/consumer-fixture.ts` with TypeScript 5.9; `npm run docs:examples` completes the packaged Agent workflow; `npm run deps:check` reports no source cycles.
274
- - Acceptance: 95 Node tests, maintained Chromium browser workflow, `git diff --check`, and `npm pack --dry-run` with 94 files passed. The npm dry-run uses a temporary cache to avoid unrelated root-owned cache files.
313
+ - Axis-free layout: Pie, Gauge, Radar, and Funnel use dedicated body geometry; `Chart#getState().layout` and `explain().layout` expose the resolved family, plot, chrome, and label regions.
314
+ - Acceptance: 97 Node tests, maintained Chromium browser workflow, `git diff --check`, and `npm pack --dry-run` with 95 files passed. The npm dry-run uses a temporary cache to avoid unrelated root-owned cache files.
275
315
 
276
316
  ## Completion Definition
277
317
 
@@ -1,6 +1,6 @@
1
1
  # iChart.js 2.0 Roadmap
2
2
 
3
- > Roadmap baseline: 2026-09-14. Current release status: `v2.0.14` includes the completed Iteration 12 structured-diagram work, Iteration 13 contract hardening, and the Bar y-axis title layout fix. Geographic charts and 3D rendering remain out of scope until explicitly reintroduced.
3
+ > Roadmap baseline: 2026-09-14. Current release status: `v2.0.15` includes the completed Iteration 12 structured-diagram work, Iteration 13 contract hardening, the Bar y-axis title layout fix, and the 13F axis-free chart layout strategy. Geographic charts and 3D rendering remain out of scope until explicitly reintroduced.
4
4
 
5
5
  ## Current Status
6
6
 
@@ -15,7 +15,7 @@
15
15
  - Iteration 10A Export Contract Hardening was implemented and released in `v2.0.5`: export representations and type errors are deterministic, optional Node raster export uses `exportAsync()`, Canvas/SVG paint semantics are aligned, and the playground server has safer port/path handling. No chart behavior or public chart type was added.
16
16
  - Iteration 11 visual preference controls are included in the `v2.0.6` release: compact per-chart settings, capability-aware visibility controls, theme-aware icon contrast, explicit font-size defaults, shared page preferences, and Agent-adjustable global settings.
17
17
  - Iteration 12A–12G is included in `v2.0.7`: shared structured-diagram contracts, Architecture layers/boundaries, Mindmap parent-child tree/radial layouts with true cubic-Bezier edges, Agent schemas/capabilities, renderer-parity edge hit testing and selection, waypoint/segment handles, persistent manual routing, and Gallery/documentation coverage. Navigation and editing remain disabled by default and require explicit host activation.
18
- - Iteration 13A–13E is complete and included in `v2.0.14`: canonical contract registry and generated capability projection, atomic Spec/data/style mutations, row-complete project validation, explicit Mindmap edge metadata, complete public TypeScript declarations and consumer fixture, module-cycle and documentation/example gates, browser acceptance, and package dry-run evidence. No chart type was added.
18
+ - Iteration 13A–13F is complete and included in `v2.0.15`: canonical contract registry and generated capability projection, atomic Spec/data/style mutations, row-complete project validation, explicit Mindmap edge metadata, complete public TypeScript declarations and consumer fixture, module-cycle and documentation/example gates, browser acceptance, package dry-run evidence, and an axis-free layout strategy for Pie, Funnel, Gauge, and Radar. No chart type was added.
19
19
  - The original `2.0.0` readiness record is historical and superseded by the published `2.0.x` releases. Current release checks are defined by `docs/agent/development/release-sop.md`.
20
20
 
21
21
  ## Iteration 4 — Agent Data Contract and Business Editing
@@ -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.14`.
13
+ For environments without npm registry access, install from GitHub as a fallback: `npm install github:wanghetommy/ichartjs#v2.0.15`.
14
14
 
15
15
  Use the package through a bundler or another environment that resolves npm ESM imports:
16
16
 
@@ -121,7 +121,7 @@ npx skills add wanghetommy/ichartjs --skill ichartjs --agent codex --global --ye
121
121
  For reproducible installation, pin the released Skill directory:
122
122
 
123
123
  ```bash
124
- npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.14/skills/ichartjs \
124
+ npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.15/skills/ichartjs \
125
125
  --agent codex --global --yes
126
126
  ```
127
127
 
@@ -22,7 +22,7 @@ npm Registry 中无作用域的 `ichartjs` 是安全占位包,并非本项目
22
22
  npx skills add wanghetommy/ichartjs --skill ichartjs
23
23
  ```
24
24
 
25
- Codex 全局无交互安装可追加 `--agent codex --global --yes`。需要固定发布版本时,安装 `https://github.com/wanghetommy/ichartjs/tree/v2.0.14/skills/ichartjs`。WorkBuddy 可通过自身 Skill 界面导入该带 Tag 的目录;只有当前 CLI 明确声明对应适配器时才使用宿主专用 `--agent` 参数。
25
+ Codex 全局无交互安装可追加 `--agent codex --global --yes`。需要固定发布版本时,安装 `https://github.com/wanghetommy/ichartjs/tree/v2.0.15/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.14`。
9
+ 无法访问 npm Registry 的环境请用 GitHub 源作为后备:`npm install github:wanghetommy/ichartjs#v2.0.15`。
10
10
 
11
11
  ```js
12
12
  import { createChart } from '@taylorwong/ichartjs';
@@ -116,7 +116,7 @@ npx skills add wanghetommy/ichartjs --skill ichartjs --agent codex --global --ye
116
116
  需要固定发布版本时,直接安装已发布的 Skill 目录:
117
117
 
118
118
  ```bash
119
- npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.14/skills/ichartjs \
119
+ npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.15/skills/ichartjs \
120
120
  --agent codex --global --yes
121
121
  ```
122
122
 
@@ -282,8 +282,8 @@
282
282
  "Short form `branding: false` and object form `branding: { enabled: false }` are both supported. Custom text, link, position, or font are intentionally NOT exposed in the current iteration."
283
283
  ]
284
284
  },
285
- "packageVersion": "2.0.14",
286
- "runtimeVersion": "2.0.14",
285
+ "packageVersion": "2.0.15",
286
+ "runtimeVersion": "2.0.15",
287
287
  "chartTypes": [
288
288
  "line",
289
289
  "area",
@@ -1,3 +1,4 @@
1
+ // Executable Agent integration example covering the full inspect-to-destroy lifecycle.
1
2
  import { pathToFileURL } from 'node:url';
2
3
  import {
3
4
  createChart,
@@ -8,6 +9,7 @@ import {
8
9
  recommend,
9
10
  validateSpec
10
11
  } from '@taylorwong/ichartjs';
12
+ // Small deterministic fixture with stable IDs for reproducible lineage checks.
11
13
  export const sampleRows = [
12
14
  { id: 'jan', month: 'Jan', revenue: 120, cost: 82 },
13
15
  { id: 'feb', month: 'Feb', revenue: 148, cost: 91 },
@@ -15,6 +17,7 @@ export const sampleRows = [
15
17
  { id: 'apr', month: 'Apr', revenue: 176, cost: 105 }
16
18
  ];
17
19
 
20
+ // Convert the planning result into a candidate Chart Spec for validateSpec().
18
21
  function buildCandidate(rows, plan, renderer) {
19
22
  const measureFields = [plan.suggestedEncodings.measure, plan.suggestedEncodings.secondaryMeasure].filter(Boolean);
20
23
  return {
@@ -36,17 +39,21 @@ function buildCandidate(rows, plan, renderer) {
36
39
  };
37
40
  }
38
41
 
42
+ // Run the complete Agent workflow and return structured results for inspection or presentation.
39
43
  export function runAgentWorkflow(rows, options = {}) {
40
44
  const intent = options.intent || 'trend';
41
45
  const renderer = options.renderer || 'svg';
46
+ // Discover the capability contract before inspecting data and planning the chart.
42
47
  const capabilities = getCapabilities();
43
48
  const inspection = inspectData(rows);
44
49
  const plan = planChart(rows, { intent, renderer, context: options.context || 'analysis' });
45
50
 
51
+ // Return early when required planning fields are missing instead of creating an invalid chart.
46
52
  if (plan.requiredFields.length) {
47
53
  return { ok: false, stage: 'planning', capabilitiesContract: capabilities.contractVersion, inspection, plan };
48
54
  }
49
55
 
56
+ // Build and validate the candidate Spec before creating the chart.
50
57
  const candidate = buildCandidate(rows, plan, renderer);
51
58
  const validation = validateSpec(candidate);
52
59
  if (!validation.valid) {
@@ -55,6 +62,7 @@ export function runAgentWorkflow(rows, options = {}) {
55
62
 
56
63
  const chart = createChart(validation.spec);
57
64
  try {
65
+ // Read explanation, state, and export output to verify observability and data lineage.
58
66
  const explanation = chart.explain();
59
67
  const state = chart.getState();
60
68
  const expectedRecordIds = rows.map((row, index) => String(row.id ?? row.key ?? `record-${index}`));
@@ -77,10 +85,12 @@ export function runAgentWorkflow(rows, options = {}) {
77
85
  export: JSON.parse(chart.export({ type: 'json' }))
78
86
  };
79
87
  } finally {
88
+ // Always release chart resources and listeners, whether the workflow succeeds or fails.
80
89
  chart.destroy();
81
90
  }
82
91
  }
83
92
 
93
+ // Print a compact result when run directly; expose reusable workflow functions when imported.
84
94
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
85
95
  const result = runAgentWorkflow(sampleRows);
86
96
  console.log(JSON.stringify({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taylorwong/ichartjs",
3
- "version": "2.0.14",
3
+ "version": "2.0.15",
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.14/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.15/skills/ichartjs` when reproducibility matters. WorkBuddy can import the same directory through its Skill interface; do not assume a `--agent workbuddy` adapter unless the installed CLI declares it.
25
25
 
26
26
  The Skill is a workflow adapter, not the chart runtime. If the current JavaScript or TypeScript project does not already depend on iChart.js, install the matching runtime from GitHub:
27
27
 
package/src/charts.mjs CHANGED
@@ -65,18 +65,30 @@ function axisTicks(min, max, axis = {}, type = 'linear') {
65
65
  return Array.from({ length: intervals + 1 }, (_, index) => min + (max - min) * index / intervals);
66
66
  }
67
67
 
68
+ const axisFreeTypes = new Set(['pie', 'funnel', 'gauge', 'radar']);
69
+
68
70
  function chromeLayout(spec, entries = []) {
69
71
  const title = titleLayout(spec);
70
72
  const visible = spec.legend?.visible !== false && entries.length >= 1;
71
- if (!visible) return { title, legend: { visible: false, items: [], bottom: title.bottom }, bottom: title.bottom };
73
+ if (!visible) return { title, legend: { visible: false, items: [], position: 'top', top: title.bottom, bottom: title.bottom, left: spec.padding.left, right: spec.width - spec.padding.right, width: 0, height: 0 }, bottom: title.bottom };
72
74
  const size = fontSize(spec, 'legend', 12), lineHeight = Math.max(18, size * Number(spec.theme?.typography?.legend?.lineHeight || 1.4));
73
75
  const swatchSize = Math.max(8, Math.round(size * 0.75)), gap = Math.max(12, Math.round(size));
74
76
  const availableWidth = Math.max(1, spec.width - spec.padding.left - spec.padding.right);
75
- const maxLabelWidth = Math.max(size, availableWidth - swatchSize - 6 - gap);
77
+ const requestedPosition = axisFreeTypes.has(spec.type) && ['top', 'right', 'bottom', 'left'].includes(spec.legend?.position) ? spec.legend.position : 'top';
78
+ const sideLegend = requestedPosition === 'left' || requestedPosition === 'right';
79
+ const labelBudget = sideLegend ? Math.max(size * 4, availableWidth * 0.34 - swatchSize - 6 - gap) : availableWidth - swatchSize - 6 - gap;
80
+ const maxLabelWidth = Math.max(size, labelBudget);
76
81
  const measured = entries.map((entry, index) => {
77
82
  const rawName = String(entry.name), name = truncateText(rawName, maxLabelWidth, size);
78
83
  return { ...entry, name, rawName, truncated: name !== rawName, index, width: swatchSize + 6 + estimateTextWidth(name, size) + gap };
79
84
  });
85
+ if (sideLegend) {
86
+ const width = Math.max(size * 4, ...measured.map(item => item.width));
87
+ const top = Math.max(spec.padding.top, title.bottom ? title.bottom + 8 : spec.padding.top);
88
+ const items = measured.map((item, index) => ({ ...item, x: requestedPosition === 'left' ? spec.padding.left : spec.width - spec.padding.right - width, y: top + index * lineHeight + lineHeight / 2, swatchSize }));
89
+ const bottom = top + measured.length * lineHeight;
90
+ return { title, legend: { visible: true, items, position: requestedPosition, top, bottom, left: requestedPosition === 'left' ? spec.padding.left : spec.width - spec.padding.right - width, right: requestedPosition === 'left' ? spec.padding.left + width : spec.width - spec.padding.right, width, height: measured.length * lineHeight, truncatedCount: measured.filter(item => item.truncated).length }, bottom: title.bottom };
91
+ }
80
92
  const rows = [];
81
93
  measured.forEach(item => {
82
94
  let row = rows.at(-1);
@@ -84,7 +96,8 @@ function chromeLayout(spec, entries = []) {
84
96
  row.items.push(item);
85
97
  row.width += item.width;
86
98
  });
87
- const top = title.bottom ? title.bottom + 8 : Math.max(8, (spec.padding.top - rows.length * lineHeight) / 2);
99
+ const legendHeight = rows.length * lineHeight;
100
+ const top = requestedPosition === 'bottom' ? spec.height - spec.padding.bottom - legendHeight : title.bottom ? title.bottom + 8 : Math.max(8, (spec.padding.top - legendHeight) / 2);
88
101
  const items = [];
89
102
  rows.forEach((row, rowIndex) => {
90
103
  let x = Math.max(spec.padding.left, spec.width - spec.padding.right - row.width);
@@ -92,7 +105,7 @@ function chromeLayout(spec, entries = []) {
92
105
  row.items.forEach(item => { items.push({ ...item, x, y, swatchSize }); x += item.width; });
93
106
  });
94
107
  const bottom = top + rows.length * lineHeight;
95
- return { title, legend: { visible: true, items, bottom, truncatedCount: measured.filter(item => item.truncated).length }, bottom: Math.max(title.bottom, bottom) };
108
+ return { title, legend: { visible: true, items, position: requestedPosition, top, bottom, left: spec.padding.left, right: spec.width - spec.padding.right, width: availableWidth, height: legendHeight, truncatedCount: measured.filter(item => item.truncated).length }, bottom: requestedPosition === 'bottom' ? title.bottom : Math.max(title.bottom, bottom) };
96
109
  }
97
110
 
98
111
  function styledSpec(spec) {
@@ -100,7 +113,7 @@ function styledSpec(spec) {
100
113
  return { ...spec, theme, colors: spec.colors || theme.colors, background: spec.background || theme.background, padding: spec.padding || theme.layout.padding };
101
114
  }
102
115
 
103
- function layout(spec, data, legendEntries = []) {
116
+ function layout(spec, data, legendEntries = [], layoutOptions = {}) {
104
117
  const p = spec.padding, width = spec.width, height = spec.height;
105
118
  const chrome = chromeLayout(spec, legendEntries);
106
119
  const xEncoding = spec.encoding.x || { field: 'name', type: 'category' };
@@ -140,26 +153,32 @@ function layout(spec, data, legendEntries = []) {
140
153
  const matrixYField = spec.encoding.y?.field || 'y', matrixTexts = spec.type === 'heatmap' ? data.rows.map(row => String(row[matrixYField] ?? '')) : [];
141
154
  const leftTexts = spec.type === 'bar' ? categoryTexts : yTicks.map(value => formatValue(value, spec.yAxis?.format, spec.locale));
142
155
  const leftLabelWidth = maxWidth(leftTexts), rightLabelWidth = yEncodings.length > 1 && !stackMode ? maxWidth(rightTicks.map(value => formatValue(value, spec.yAxis?.right?.format, spec.locale))) : 0;
143
- const centeredPlot = ['pie', 'funnel', 'gauge', 'radar'].includes(spec.type);
156
+ const axisFree = axisFreeTypes.has(spec.type);
144
157
  const sideLabelWidth = Math.max(spec.type === 'bar' ? leftLabelWidth : 0, spec.type === 'heatmap' ? maxWidth(matrixTexts) : 0);
145
158
  const leftTitleBand = spec.yAxis?.title ? axisSize + 16 : 0;
146
159
  const rightTitleBand = spec.yAxis?.right?.title && yEncodings.length > 1 && !stackMode ? axisSize + 16 : 0;
147
160
  const cartesianLeft = 8 + leftTitleBand + leftLabelWidth + 10;
148
- const plotLeft = centeredPlot
149
- ? Math.max(p.left, p.right)
161
+ const plotLeft = axisFree
162
+ ? Math.max(p.left, chrome.legend.position === 'left' ? chrome.legend.right + 12 : p.left)
150
163
  : spec.type === 'bar'
151
164
  ? Math.max(p.left, spec.yAxis?.title ? cartesianLeft : sideLabelWidth + 24)
152
165
  : spec.type === 'heatmap'
153
166
  ? Math.min(width * 0.35, Math.max(p.left, sideLabelWidth + 24))
154
167
  : Math.max(p.left, cartesianLeft);
155
- const plotRight = centeredPlot ? plotLeft : Math.max(p.right, rightLabelWidth ? rightLabelWidth + rightTitleBand + 18 : 0);
168
+ const plotRight = axisFree
169
+ ? Math.max(p.right, chrome.legend.position === 'right' ? spec.width - chrome.legend.left + 12 : p.right)
170
+ : Math.max(p.right, rightLabelWidth ? rightLabelWidth + rightTitleBand + 18 : 0);
156
171
  const labelTopReserve = spec.type === 'column' && spec.labels?.enabled ? Math.ceil(fontSize(spec, 'label', 12) * 1.5 + 4) : 0;
157
- const plotTop = Math.max(p.top, chrome.bottom + (chrome.bottom ? 12 : 0)) + labelTopReserve;
158
172
  const plotWidth = Math.max(1, width - plotLeft - plotRight);
159
173
  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;
160
174
  const xLabels = axisLabelLayout(spec, spec.type === 'bar' ? [] : axisLabelTexts, plotWidth);
175
+ const topChrome = chrome.legend.position === 'top' && chrome.legend.visible ? chrome.legend.bottom + 12 : Math.max(p.top, chrome.title.bottom + (chrome.title.bottom ? 12 : 0));
176
+ const axisFreeBottomPadding = Number(layoutOptions.axisFreeBottomPadding ?? p.bottom);
177
+ const bottomChrome = chrome.legend.position === 'bottom' && chrome.legend.visible ? chrome.legend.top - 12 : height - axisFreeBottomPadding;
178
+ const plotTop = axisFree ? Math.max(p.top, topChrome) + labelTopReserve : Math.max(p.top, chrome.bottom + (chrome.bottom ? 12 : 0)) + labelTopReserve;
161
179
  const bottomReserve = Math.max(p.bottom, Math.ceil(16 + xLabels.projectedHeight + (spec.xAxis?.title ? 20 : 0)));
162
- const plot = { x: plotLeft, y: plotTop, width: plotWidth, height: Math.max(1, height - plotTop - bottomReserve) };
180
+ const plotBottom = axisFree ? Math.min(height - axisFreeBottomPadding, bottomChrome) : height - bottomReserve;
181
+ const plot = { x: plotLeft, y: plotTop, width: plotWidth, height: Math.max(1, plotBottom - plotTop) };
163
182
  const axisTitles = {
164
183
  y: spec.yAxis?.title ? truncateText(spec.yAxis.title, plot.height, axisSize) : null,
165
184
  right: spec.yAxis?.right?.title && yEncodings.length > 1 && !stackMode ? truncateText(spec.yAxis.right.title, plot.height, axisSize) : null
@@ -170,7 +189,7 @@ function layout(spec, data, legendEntries = []) {
170
189
  const rightTransform = value => rightType === 'log' ? Math.log10(Math.max(0.000001, Number(value))) : Number(value);
171
190
  const yRight = value => plot.y + plot.height - ((rightTransform(value) - rightTransform(rightMin)) / (rightTransform(rightMax) - rightTransform(rightMin) || 1)) * plot.height;
172
191
  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' });
173
- return { plot, chrome, xLabels, axisTitles, axisTitleWarnings, axisLayout: { size: axisSize, leftLabelWidth, rightLabelWidth }, compact: width < 360 || height < 240, recommendedSize: { minWidth: 280, minHeight: 220 }, xField, xEncoding, temporal, quantitativeX, xMin, xMax, yField, yEncodings, categories, rawMin, rawMax, min, max, rawRightMin, rawRightMax, rightMin, rightMax, yTicks, rightTicks, axes: { y: axisInfo([rawMin, rawMax], [min, max], yTicks, spec.yAxis || {}), right: axisInfo([rawRightMin, rawRightMax], [rightMin, rightMax], rightTicks, spec.yAxis?.right || {}) }, x, y, yRight, axisType, rightType };
192
+ return { plot, chrome, layoutFamily: axisFree ? spec.type : 'cartesian', xLabels, axisTitles, axisTitleWarnings, axisLayout: { size: axisSize, leftLabelWidth, rightLabelWidth }, compact: width < 360 || height < 240, recommendedSize: { minWidth: 280, minHeight: 220 }, xField, xEncoding, temporal, quantitativeX, xMin, xMax, yField, yEncodings, categories, rawMin, rawMax, min, max, rawRightMin, rawRightMax, rightMin, rightMax, yTicks, rightTicks, axes: { y: axisInfo([rawMin, rawMax], [min, max], yTicks, spec.yAxis || {}), right: axisInfo([rawRightMin, rawRightMax], [rightMin, rightMax], rightTicks, spec.yAxis?.right || {}) }, x, y, yRight, axisType, rightType };
174
193
  }
175
194
 
176
195
  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 })); }
@@ -270,11 +289,11 @@ function addHeatmapScene(scene, spec, data, state, colors) {
270
289
  }
271
290
 
272
291
  function addRadarScene(scene, spec, data, state, colors) {
273
- const indicators = spec.indicators, 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) * .38;
292
+ const indicators = spec.indicators, 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) * .44;
274
293
  if (indicators.some(indicator => !Number.isFinite(Number(indicator.min)) || !Number.isFinite(Number(indicator.max)))) data.warnings.push({ code: 'AMBIGUOUS_RADAR_DOMAIN', message: 'Declare finite min and max for every radar indicator, especially for mixed units.' });
275
294
  const point = (indicator, index, ratio = 1) => { const angle = -Math.PI / 2 + index * Math.PI * 2 / indicators.length; return { x: cx + Math.cos(angle) * radius * ratio, y: cy + Math.sin(angle) * radius * ratio }; };
276
295
  [0.25, 0.5, 0.75, 1].forEach((ratio, level) => scene.add(new SceneNode({ id: `radar-grid-${level}`, type: 'path', geometry: { points: indicators.map((indicator, index) => point(indicator, index, ratio)), closed: true }, style: { fill: 'none', stroke: spec.theme.grid, strokeWidth: spec.theme.marks.gridWidth } })));
277
- const labelSize = fontSize(spec, 'axis', 12), occupied = [], labelArea = { x: 4, y: Math.max(4, state.chrome.bottom + 4), width: spec.width - 8, height: Math.max(1, spec.height - spec.padding.bottom - Math.max(4, state.chrome.bottom + 4)) };
296
+ const labelSize = fontSize(spec, 'axis', 12), occupied = [], labelTop = Math.max(4, state.plot.y - labelSize * 2), labelArea = { x: 4, y: labelTop, width: spec.width - 8, height: Math.max(1, state.plot.y + state.plot.height - labelTop) };
278
297
  let hiddenLabels = 0;
279
298
  indicators.forEach((indicator, index) => {
280
299
  const edge = point(indicator, index), angle = -Math.PI / 2 + index * Math.PI * 2 / indicators.length;
@@ -364,6 +383,9 @@ export function buildScene(spec) {
364
383
  bottom: Number(baseSpec.padding?.bottom ?? 48) + (brandingEnabled ? 18 : 0),
365
384
  left: Number(baseSpec.padding?.left ?? 56),
366
385
  };
386
+ const themePadding = baseSpec.theme.layout.padding || {};
387
+ const customPadding = ['top', 'right', 'bottom', 'left'].some(key => Number(baseSpec.padding?.[key]) !== Number(themePadding[key]));
388
+ const axisFreeBottomPadding = customPadding ? effectivePadding.bottom : Math.max(28, Math.min(34, Number(themePadding.bottom ?? effectivePadding.bottom)));
367
389
  spec = { ...baseSpec, padding: effectivePadding };
368
390
  const isProjectFamily = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type);
369
391
  if (isProjectFamily) {
@@ -381,7 +403,7 @@ export function buildScene(spec) {
381
403
  const colors = spec.colors;
382
404
  const series = Array.isArray(spec.encoding.y) ? spec.encoding.y : [{ ...spec.encoding.y, name: spec.encoding.y?.name || spec.encoding.y?.field }];
383
405
  const entries = legendEntries(spec, data, series, colors);
384
- const state = layout(spec, data, entries);
406
+ const state = layout(spec, data, entries, { axisFreeBottomPadding });
385
407
  if (state.chrome.legend.truncatedCount) data.warnings.push({ code: 'LEGEND_OVERFLOW', path: 'legend', count: state.chrome.legend.truncatedCount, message: `${state.chrome.legend.truncatedCount} legend label${state.chrome.legend.truncatedCount === 1 ? '' : 's'} exceeded the available width and was truncated.`, suggestion: 'Use shorter category names or a wider chart.' });
386
408
  state.axisTitleWarnings.forEach(({ path, title, availableWidth }) => data.warnings.push({ code: 'TITLE_TRUNCATED', path, availableWidth, message: `Axis title "${title}" exceeded the available vertical plot space and was truncated.`, suggestion: 'Use a taller chart or a shorter axis title.' }));
387
409
  scene._labelArea = state.plot;
@@ -402,7 +424,7 @@ export function buildScene(spec) {
402
424
  } else if (spec.type === 'scatter') {
403
425
  data.rows.forEach((row, index) => { const xValue = Number(row[state.xField]), yValue = Number(row[state.yField]); if (!Number.isFinite(xValue) || !Number.isFinite(yValue)) return; const geometry = { cx: state.x(xValue), cy: state.y(yValue), r: 5 }, dataRef = { seriesIndex: 0, dataIndex: index, field: state.yField }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'circle', geometry, bounds: { x: geometry.cx - 8, y: geometry.cy - 8, width: 16, height: 16 }, style: { fill: colors[0] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, yValue, geometry.cx, geometry.cy - 8, dataRef, state.plot, { x: geometry.cx, y: geometry.cy, radius: 5 }); });
404
426
  } else if (spec.type === 'pie') {
405
- const categoryField = spec.encoding.category?.field || 'name', valueField = spec.encoding.value?.field || 'value', values = data.rows.map(row => Number(row[valueField])), total = values.reduce((sum, value) => sum + (Number.isFinite(value) && value > 0 ? value : 0), 0), negativeCount = values.filter(value => Number.isFinite(value) && value < 0).length, cx = state.plot.x + state.plot.width / 2, cy = state.plot.y + state.plot.height / 2, radius = Math.min(state.plot.width, state.plot.height) * 0.38, innerR = radius * Number(spec.innerRadius || 0); let angle = -Math.PI / 2;
427
+ const categoryField = spec.encoding.category?.field || 'name', valueField = spec.encoding.value?.field || 'value', values = data.rows.map(row => Number(row[valueField])), total = values.reduce((sum, value) => sum + (Number.isFinite(value) && value > 0 ? value : 0), 0), negativeCount = values.filter(value => Number.isFinite(value) && value < 0).length, cx = state.plot.x + state.plot.width / 2, cy = state.plot.y + state.plot.height / 2, radius = Math.min(state.plot.width, state.plot.height) * 0.46, innerR = radius * Number(spec.innerRadius || 0); let angle = -Math.PI / 2;
406
428
  if (negativeCount) data.warnings.push({ code: 'NEGATIVE_VALUE_DROPPED', path: 'encoding.value', count: negativeCount, message: `Pie ignores ${negativeCount} negative value${negativeCount === 1 ? '' : 's'} when calculating shares.`, suggestion: 'Use non-negative part-to-whole values or choose a Cartesian chart for signed measures.' });
407
429
  if (!(total > 0)) { data.warnings.push({ code: 'ZERO_TOTAL', path: 'encoding.value', message: 'Pie requires a positive total.', suggestion: 'Provide at least one positive value or render an explicit empty state.' }); addText(scene, 'pie-zero-total', spec.emptyText || 'No positive values', cx, cy, { fill: spec.theme.muted, font: font(spec, 'subtitle'), textAnchor: 'middle' }); }
408
430
  else data.rows.forEach((row, index) => { const rawValue = values[index], value = Number.isFinite(rawValue) ? Math.max(0, rawValue) : 0, end = angle + value / total * Math.PI * 2, middle = angle + (end - angle) / 2, fill = colors[index % colors.length], dataRef = { seriesIndex: 0, dataIndex: index, category: row[categoryField], value, rawValue }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'arc', geometry: { cx, cy, r: radius, innerR, start: angle, end }, bounds: { x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2 }, style: { fill, stroke: spec.theme.background, strokeWidth: 1 }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value / total, cx + Math.cos(middle) * radius * .72, cy + Math.sin(middle) * radius * .72, dataRef, state.plot, null, spec.labels?.format || { style: 'percent' }, fill); angle = end; });
@@ -410,7 +432,7 @@ export function buildScene(spec) {
410
432
  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;
411
433
  data.rows.forEach((row, index) => { const value = Number(row[valueField]) || 0, ratio = Math.max(0.1, value / maxValue), fill = colors[index % colors.length]; const width = state.plot.width * ratio; const geometry = { x: state.plot.x + (state.plot.width - width) / 2, y: state.plot.y + index * segmentHeight, width, height: Math.max(2, segmentHeight - 3) }, dataRef = { seriesIndex: 0, dataIndex: index, field: valueField }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 + 4, dataRef, state.plot, null, null, fill); });
412
434
  } else if (spec.type === 'gauge') {
413
- 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;
435
+ 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.68, radius = Math.min(state.plot.width * 0.42, state.plot.height * 0.62), start = Math.PI, end = start + Math.PI * ratio;
414
436
  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.' });
415
437
  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.' });
416
438
  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' });
package/src/index.mjs CHANGED
@@ -455,13 +455,13 @@ export class Chart {
455
455
  const clampedValues = warnings.filter(item => item.code === 'VALUE_CLAMPED').reduce((sum, item) => sum + Number(item.count || 1), 0);
456
456
  return { version: '1.0', status: isEmpty ? 'empty' : warnings.length ? 'degraded' : 'ready', renderable: !isEmpty, issues: [...new Set(warnings.map(item => item.code))], metrics: { warnings: warnings.length, suppressedLabels, clampedValues, renderedMarks: marks.length } };
457
457
  }
458
- 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) }; }
458
+ getState() { const brandingSignature = discoverCapabilities().branding.signature, warnings = this._getDiagnostics(), layoutState = this.model.state?.plot ? { family: this.model.state.layoutFamily || null, plot: clone(this.model.state.plot), chrome: clone(this.model.state.chrome || null), labels: clone(this.model.state.labelLayout || null) } : null; 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 }), layout: layoutState, 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) }; }
459
459
  getProjectAnalytics() { return clone(this.model.state?.projectAnalytics || null); }
460
460
  getLinkedState() { return clone(this.model.state?.linked || null); }
461
461
  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(); }
462
462
  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(); }
463
463
  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]) }; }
464
- explain() { const explanation = explainChart(this.spec, this.model), state = this.getState(); return { ...explanation, warnings: state.warnings, health: state.health }; }
464
+ explain() { const explanation = explainChart(this.spec, this.model), state = this.getState(); return { ...explanation, layout: state.layout, warnings: state.warnings, health: state.health }; }
465
465
  getAccessibleDescription() { const description = this.spec.accessibility?.description || this.spec.title?.text || `${this.spec.type} chart`; return `${description}; ${this.model.data.rows.length} data items.`; }
466
466
  inspectDataSchema() { return inspectDataSchema(this.spec.data.schema || this.spec.schema); }
467
467
  validateData() { return validateData(this.toDataTable(), this.spec.data.schema || this.spec.schema, this.spec.validationOptions); }
@@ -771,4 +771,4 @@ export { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, cr
771
771
 
772
772
  export { contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin };
773
773
  export { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, mountChartSettings, normalizePreferences, validatePreferences };
774
- export const iChart = { version: '2.0.14', createChart, ChartValidationError, inspectData, normalizeData, binData, applyTransforms, data, getCapabilities, getChartCapability, getPreferenceCapabilities, planChart, recommend, explainChart, contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, createPreferencesStore, defaultPreferences, normalizePreferences, mergePreferences, validatePreferences, applyPreferencesToSpec, mountChartSettings, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries, normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };
774
+ export const iChart = { version: '2.0.15', createChart, ChartValidationError, inspectData, normalizeData, binData, applyTransforms, data, getCapabilities, getChartCapability, getPreferenceCapabilities, planChart, recommend, explainChart, contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, createPreferencesStore, defaultPreferences, normalizePreferences, mergePreferences, validatePreferences, applyPreferencesToSpec, mountChartSettings, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries, normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };