@taylorwong/ichartjs 2.0.14 → 2.0.16
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 +13 -0
- package/README.md +2 -2
- package/docs/agent/charting-scenario.md +7 -0
- package/docs/agent/coding-agent-integration.md +1 -1
- package/docs/agent/development/2.0-release-readiness.md +1 -1
- package/docs/agent/development/iteration-13.md +41 -1
- package/docs/agent/development/roadmap.md +2 -2
- package/docs/agent/diagram-scenario.md +2 -0
- package/docs/agent/frontend-integration.md +1 -1
- package/docs/agent/project-scenario.md +5 -1
- package/docs/agent/runtime-contract.md +1 -1
- package/docs/agent/usage-scenarios.md +1 -1
- package/docs/agent/zh-CN/charting-scenario.md +7 -0
- package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
- package/docs/agent/zh-CN/diagram-scenario.md +2 -0
- package/docs/agent/zh-CN/frontend-integration.md +1 -1
- package/docs/agent/zh-CN/project-scenario.md +3 -0
- package/docs/agent/zh-CN/runtime-contract.md +1 -1
- package/docs/agent/zh-CN/usage-scenarios.md +1 -1
- package/docs/manifests/capabilities.json +8 -7
- package/examples/agent-workflow.mjs +10 -0
- package/package.json +1 -1
- package/skills/ichartjs/SKILL.md +5 -3
- package/src/charts.mjs +41 -19
- package/src/contract-registry.mjs +3 -3
- package/src/diagram.mjs +12 -4
- package/src/index.mjs +4 -4
- package/src/project.mjs +89 -35
- package/src/spec.mjs +33 -4
- package/types/index.d.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.16 - 2026-09-25
|
|
4
|
+
|
|
5
|
+
- Hardened Timeline and Milestone date layouts with proportional positioning, date-aware ticks, edge padding, collision-aware marker staggering, and `TIMELINE_COLLISION` diagnostics for physically constrained charts.
|
|
6
|
+
- Fixed P0–P2 Agent contract issues across Funnel, Radar, Gauge, diagram labels and edges, Gantt dependencies, localized project output, title normalization, and chart-specific diagnostics.
|
|
7
|
+
- Synchronized package, runtime, Playground, documentation, Skill, and capability manifest references for `v2.0.16`.
|
|
8
|
+
|
|
9
|
+
## 2.0.15 - 2026-09-24
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
- Exposed resolved layout geometry through `getState().layout` and `explain().layout`, with focused regression coverage for axis-free charts.
|
|
13
|
+
- Removed archived iChart.js 1.x sources and obsolete 2.0 example snapshots, and added `.trae` to the ignore rules.
|
|
14
|
+
- Synchronized package, runtime, Playground, documentation, and Skill references for `v2.0.15`.
|
|
15
|
+
|
|
3
16
|
## 2.0.14 - 2026-09-23
|
|
4
17
|
|
|
5
18
|
- 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.
|
|
47
|
+
As a fallback for environments without npm access, install directly from GitHub: `npm install github:wanghetommy/ichartjs#v2.0.16`.
|
|
48
48
|
|
|
49
49
|
### Optional Agent Skill
|
|
50
50
|
|
|
@@ -66,7 +66,7 @@ For a non-interactive global Codex installation:
|
|
|
66
66
|
npx skills add wanghetommy/ichartjs --skill ichartjs --agent codex --global --yes
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
For a release-pinned installation, use `npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.
|
|
69
|
+
For a release-pinned installation, use `npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.16/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
|
|
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Agent usage and development guide for generic data analysis and metric visualization.
|
|
4
4
|
|
|
5
|
+
## Capability Boundaries
|
|
6
|
+
|
|
7
|
+
- `scatter` is a single x/y measure pair; it does not provide series or legend encoding.
|
|
8
|
+
- `heatmap` uses matrix row/column labels from `encoding.x` and `encoding.y`; Cartesian `xAxis`/`yAxis` configuration is not supported.
|
|
9
|
+
- `funnel` renders stage names from `encoding.category` (default `name`) and optional values from `labels.enabled`; it has no legend.
|
|
10
|
+
- `area` and `column` accept at most two quantitative `encoding.y` measures.
|
|
11
|
+
|
|
5
12
|
## Supported Types
|
|
6
13
|
|
|
7
14
|
| Type | Typical intent | Recommended renderer |
|
|
@@ -33,7 +33,7 @@ Install the official Skill with the standard Agent Skills CLI:
|
|
|
33
33
|
npx skills add wanghetommy/ichartjs --skill ichartjs
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
For global non-interactive Codex setup, append `--agent codex --global --yes`. To pin the released workflow, install `https://github.com/wanghetommy/ichartjs/tree/v2.0.
|
|
36
|
+
For global non-interactive Codex setup, append `--agent codex --global --yes`. To pin the released workflow, install `https://github.com/wanghetommy/ichartjs/tree/v2.0.16/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.
|
|
43
|
+
The historical 2.0.0 release decision remains unchanged. Release `v2.0.16` 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, Node tests, Chromium browser acceptance, package dry-run verification, the 13F axis-free layout strategy, and Timeline/Milestone layout hardening. 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
|
-
-
|
|
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.
|
|
3
|
+
> Roadmap baseline: 2026-09-14. Current release status: `v2.0.16` includes the completed Iteration 12 structured-diagram work, Iteration 13 contract hardening, the Bar y-axis title layout fix, the 13F axis-free chart layout strategy, and Timeline/Milestone layout hardening. 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–
|
|
18
|
+
- Iteration 13A–13F is complete and included in `v2.0.16`: 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, an axis-free layout strategy for Pie, Funnel, Gauge, and Radar, and Timeline/Milestone layout hardening. 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
|
|
@@ -27,6 +27,8 @@ Architecture layers are horizontal bands ordered from top to bottom. Automatic l
|
|
|
27
27
|
}
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
`from` and `to` are the only edge endpoint fields. `source` and `target` are not aliases and return a structured validation error. Display text uses `label` for nodes, lanes, layers, groups, and boundaries; using `name` is diagnosed rather than silently treated as display text.
|
|
31
|
+
|
|
30
32
|
For a mindmap, prefer the compact parent contract:
|
|
31
33
|
|
|
32
34
|
```js
|
|
@@ -10,7 +10,7 @@ This is the production component path. For one-off files or Agent-led repository
|
|
|
10
10
|
npm install @taylorwong/ichartjs@^2
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
For environments without npm registry access, install from GitHub as a fallback: `npm install github:wanghetommy/ichartjs#v2.0.
|
|
13
|
+
For environments without npm registry access, install from GitHub as a fallback: `npm install github:wanghetommy/ichartjs#v2.0.16`.
|
|
14
14
|
|
|
15
15
|
Use the package through a bundler or another environment that resolves npm ESM imports:
|
|
16
16
|
|
|
@@ -24,7 +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;
|
|
27
|
+
- Project chart date axes are derived from `date`, `start`, and `end` records in the current contract; timeline and milestone use date-proportional horizontal positions while rows remain evenly spaced vertically. Generic `xAxis.title/format` and `xAxis.min/max` settings do not customize them.
|
|
28
28
|
|
|
29
29
|
## Agent Workflow
|
|
30
30
|
|
|
@@ -40,14 +40,18 @@ Agent usage and development guide for project planning, delivery tracking, and p
|
|
|
40
40
|
- Gantt `start` and `end` must be valid dates, and `end` cannot precede `start`.
|
|
41
41
|
- `progress` uses the `0–100` percentage convention.
|
|
42
42
|
- `dependencies` use stable task IDs, either as strings or `{ id, type, lag, lead }` objects, and must form an acyclic graph.
|
|
43
|
+
- `dependsOn` is not an alias; it is diagnosed and ignored. Use the canonical `dependencies` field.
|
|
43
44
|
- Dependency objects support `finish-to-start`, `start-to-start`, `finish-to-finish`, and `start-to-finish`. Rendering uses the matching task endpoints; a clear forward finish-to-start relationship uses a compact three-segment route, while overlapping or reverse relationships use a safe outer route.
|
|
44
45
|
- Issue aging requires an explicit ISO `today` reference; missing or invalid dates produce warnings instead of guessed buckets.
|
|
45
46
|
- Calendar-aware scheduling may also use dependency objects with explicit `type`, `lag`, and `lead`.
|
|
46
47
|
- `baselineStart`/`baselineEnd` and `actualStart`/`actualEnd` should be treated as explicit source inputs, not inferred values.
|
|
48
|
+
- Milestone overlays use `baselineDate` and `actualDate`; timeline and milestone display text is canonically `title` (legacy `name` and `label` remain accepted with the normal project label fallback).
|
|
47
49
|
- Burndown `scopeChange` represents scope movement, not completed work.
|
|
48
50
|
- A forecast is an estimate derived from current samples, not a commitment or fact.
|
|
49
51
|
- Capacity warnings, risk quadrants, and aging buckets should stay explainable from source fields.
|
|
50
52
|
- Gantt, Timeline, and Milestone reserve their left label column from Unicode-aware text widths. Labels wider than the bounded column are truncated by rendered width rather than character count.
|
|
53
|
+
- Timeline and Milestone place events on a date-proportional horizontal domain with a small visual edge padding. Nearby markers are automatically staggered when possible; if the available height is insufficient, the result includes `TIMELINE_COLLISION` and suggests increasing height or reducing events.
|
|
54
|
+
- `locale` also applies to project date ticks, burndown forecast text, and project tooltips. Use `locale: 'zh-CN'` or another BCP 47 locale when those runtime labels must be localized.
|
|
51
55
|
|
|
52
56
|
## Editing
|
|
53
57
|
|
|
@@ -16,7 +16,7 @@ Iteration 8 adds per-chart profiles through `getChartCapability(type)`. Each pro
|
|
|
16
16
|
|
|
17
17
|
`planChart(data, { intent, renderer })` returns a versioned planning result with a primary chart, alternatives, confidence, reasons, required fields, suggested encodings, assumptions, warnings, unsupported requests, safe next actions, and the selected capability profile. `intent` must be one exact token from `getCapabilities().intents`; natural-language prose must be mapped before planning. An unknown token returns `UNKNOWN_INTENT` and a fallback plan, so Agents must inspect warnings before accepting `primary`. Planning never invents business meaning, units, dates, or missing fields.
|
|
18
18
|
|
|
19
|
-
Unknown intent results also include `intentKnown`, `intentSuggestions`, and `fallbackUsed`. A chart Spec is chart-specific: Cartesian channels are `x`/`y`, Pie/Funnel channels are `category`/`value`, Gauge uses `value`, Heatmap channels are `x`/`y`/`color`, and Radar fields live in `indicators`. `validateSpec()` rejects unsupported channels and missing fields. Gauge requires an explicit `domain` and reports `VALUE_CLAMPED` when the rendered value exceeds it. Pie reports `NEGATIVE_VALUE_DROPPED` for negative input values
|
|
19
|
+
Unknown intent results also include `intentKnown`, `intentSuggestions`, and `fallbackUsed`. A chart Spec is chart-specific: Cartesian channels are `x`/`y`, Pie/Funnel channels are `category`/`value`, Gauge uses `value`, Heatmap channels are `x`/`y`/`color`, and Radar fields live in `indicators`. `validateSpec()` rejects unsupported channels and missing fields. Gauge requires an explicit `domain` and reports `VALUE_CLAMPED` when the rendered value exceeds it. Pie reports `NEGATIVE_VALUE_DROPPED` for negative input values; `ZERO_TOTAL` is a validation error because no positive share can render.
|
|
20
20
|
|
|
21
21
|
## Spec Rules
|
|
22
22
|
|
|
@@ -121,7 +121,7 @@ npx skills add wanghetommy/ichartjs --skill ichartjs --agent codex --global --ye
|
|
|
121
121
|
For reproducible installation, pin the released Skill directory:
|
|
122
122
|
|
|
123
123
|
```bash
|
|
124
|
-
npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.
|
|
124
|
+
npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.16/skills/ichartjs \
|
|
125
125
|
--agent codex --global --yes
|
|
126
126
|
```
|
|
127
127
|
|
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
用于通用指标分析和数据展示的 Agent 使用与开发指南。
|
|
4
4
|
|
|
5
|
+
## 能力边界
|
|
6
|
+
|
|
7
|
+
- `scatter` 只支持一组 x/y 数值字段,不提供 Series 或 Legend 编码。
|
|
8
|
+
- `heatmap` 使用 `encoding.x` 和 `encoding.y` 的矩阵行列标签,不支持笛卡尔 `xAxis`/`yAxis` 配置。
|
|
9
|
+
- `funnel` 从 `encoding.category`(默认 `name`)渲染阶段名称,`labels.enabled` 决定是否追加数值;不提供 Legend。
|
|
10
|
+
- `area` 和 `column` 的 `encoding.y` 最多接受两个数值度量。
|
|
11
|
+
|
|
5
12
|
## 图表选择
|
|
6
13
|
|
|
7
14
|
| 类型 | 适用场景 | 推荐 Renderer |
|
|
@@ -22,7 +22,7 @@ npm Registry 中无作用域的 `ichartjs` 是安全占位包,并非本项目
|
|
|
22
22
|
npx skills add wanghetommy/ichartjs --skill ichartjs
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Codex 全局无交互安装可追加 `--agent codex --global --yes`。需要固定发布版本时,安装 `https://github.com/wanghetommy/ichartjs/tree/v2.0.
|
|
25
|
+
Codex 全局无交互安装可追加 `--agent codex --global --yes`。需要固定发布版本时,安装 `https://github.com/wanghetommy/ichartjs/tree/v2.0.16/skills/ichartjs`。WorkBuddy 可通过自身 Skill 界面导入该带 Tag 的目录;只有当前 CLI 明确声明对应适配器时才使用宿主专用 `--agent` 参数。
|
|
26
26
|
|
|
27
27
|
使用 `npx skills add wanghetommy/ichartjs --list` 验证发现结果,其中应包含 `ichartjs`。
|
|
28
28
|
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
Architecture 的 `layers` 是从上到下排列的水平分层。同层自动布局节点优先保持同一 Y 坐标并从左到右排列;只有可用宽度不足时才增加下一行。显式 `node.position` 始终优先,包括编辑后的坐标;自动布局不会回写 Spec。
|
|
15
15
|
|
|
16
|
+
边端点只使用 `from` 和 `to`;`source` 与 `target` 不是别名,会返回结构化校验错误。节点、泳道、层、分组和边界的显示文本使用 `label`;误用 `name` 时运行时会给出诊断,不会静默当成显示文本。
|
|
17
|
+
|
|
16
18
|
```js
|
|
17
19
|
{
|
|
18
20
|
type: 'architecture',
|
|
@@ -6,7 +6,7 @@ iChart.js 应作为普通 JavaScript UI 组件运行在浏览器应用中。数
|
|
|
6
6
|
npm install @taylorwong/ichartjs@^2
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
无法访问 npm Registry 的环境请用 GitHub 源作为后备:`npm install github:wanghetommy/ichartjs#v2.0.
|
|
9
|
+
无法访问 npm Registry 的环境请用 GitHub 源作为后备:`npm install github:wanghetommy/ichartjs#v2.0.16`。
|
|
10
10
|
|
|
11
11
|
```js
|
|
12
12
|
import { createChart } from '@taylorwong/ichartjs';
|
|
@@ -16,10 +16,13 @@
|
|
|
16
16
|
- 日期必须有效,Gantt 的 `end` 不得早于 `start`。
|
|
17
17
|
- `progress` 使用 `0–100` 百分比。
|
|
18
18
|
- `dependencies` 使用稳定任务 ID,支持字符串或 `{ id, type, lag, lead }` 对象,依赖图不能有环。
|
|
19
|
+
- `dependsOn` 不是别名,会被诊断并忽略;请使用正式字段 `dependencies`。
|
|
19
20
|
- 依赖类型支持 `finish-to-start`、`start-to-start`、`finish-to-finish` 和 `start-to-finish`;连线使用对应任务端点。具有净空的正向 FS 关系使用三段紧凑路径,重叠或反向关系使用外侧绕行。
|
|
20
21
|
- `scopeChange` 表示范围变化,不等于已完成工作量。
|
|
21
22
|
- Forecast 是估计结果,不能描述为承诺或事实。
|
|
22
23
|
- Gantt、Timeline 和 Milestone 根据 Unicode-aware 文本宽度预留左侧标签列;超过列宽的标签按像素省略,不按字符数截断。
|
|
24
|
+
- Timeline 和 Milestone 的横向位置按 `date` 比例计算,纵向行槽位保持均匀,并在时间域边缘保留少量视觉留白;Milestone 覆盖层使用 `baselineDate` 和 `actualDate`,显示文本正式字段为 `title`(`name` 和 `label` 仍由兼容回退逻辑接受)。相邻标记会在可能时自动错位;如果空间不足,结果会返回 `TIMELINE_COLLISION`,建议增大高度或减少事件数量。
|
|
25
|
+
- `locale` 同时作用于项目日期刻度、Burndown 预测文案和项目 Tooltip;需要中文运行时文案时使用 `locale: 'zh-CN'`。
|
|
23
26
|
|
|
24
27
|
## 编辑流程
|
|
25
28
|
|
|
@@ -16,7 +16,7 @@ Iteration 8 通过 `getChartCapability(type)` 提供逐图表能力档案,包
|
|
|
16
16
|
|
|
17
17
|
`planChart(data, { intent, renderer })` 返回版本化规划结果:主选图表、备选项、置信度、原因、缺失字段、建议编码、假设、警告、不支持请求和安全下一步。规划不会虚构业务含义、单位、日期或缺失字段。
|
|
18
18
|
|
|
19
|
-
未知 intent 还会返回 `intentKnown`、`intentSuggestions` 和 `fallbackUsed`。Spec 的通道按图表类型约束:笛卡尔图表是 `x`/`y`,Pie/Funnel 是 `category`/`value`,Gauge 只使用 `value`,Heatmap 是 `x`/`y`/`color`,Radar 字段位于 `indicators`。`validateSpec()` 会拒绝不支持的通道和缺失字段。Gauge 必须显式声明 `domain`,超出范围时会报告 `VALUE_CLAMPED`;Pie
|
|
19
|
+
未知 intent 还会返回 `intentKnown`、`intentSuggestions` 和 `fallbackUsed`。Spec 的通道按图表类型约束:笛卡尔图表是 `x`/`y`,Pie/Funnel 是 `category`/`value`,Gauge 只使用 `value`,Heatmap 是 `x`/`y`/`color`,Radar 字段位于 `indicators`。`validateSpec()` 会拒绝不支持的通道和缺失字段。Gauge 必须显式声明 `domain`,超出范围时会报告 `VALUE_CLAMPED`;Pie 的负值报告 `NEGATIVE_VALUE_DROPPED`,没有正数占比时 `ZERO_TOTAL` 会使校验失败。
|
|
20
20
|
|
|
21
21
|
`validateSpec()` 分开返回 `errors`、`warnings` 和 `normalizations`;诊断包含稳定代码、JSON 路径、期望值和修复建议。`chart.explain()` 返回编码、转换、交互、假设、警告、稳定记录血缘和无障碍摘要。
|
|
22
22
|
|
|
@@ -116,7 +116,7 @@ npx skills add wanghetommy/ichartjs --skill ichartjs --agent codex --global --ye
|
|
|
116
116
|
需要固定发布版本时,直接安装已发布的 Skill 目录:
|
|
117
117
|
|
|
118
118
|
```bash
|
|
119
|
-
npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.
|
|
119
|
+
npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.16/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.
|
|
286
|
-
"runtimeVersion": "2.0.
|
|
285
|
+
"packageVersion": "2.0.16",
|
|
286
|
+
"runtimeVersion": "2.0.16",
|
|
287
287
|
"chartTypes": [
|
|
288
288
|
"line",
|
|
289
289
|
"area",
|
|
@@ -771,11 +771,12 @@
|
|
|
771
771
|
"invalid-data-state": "supported",
|
|
772
772
|
"export": "supported",
|
|
773
773
|
"branding": "supported",
|
|
774
|
-
"
|
|
774
|
+
"matrix-labels": "supported",
|
|
775
775
|
"labels": "supported",
|
|
776
776
|
"formatting": "supported",
|
|
777
777
|
"color-scale": "supported",
|
|
778
778
|
"missing-values": "supported",
|
|
779
|
+
"axes": "not-applicable",
|
|
779
780
|
"zoom": "not-applicable",
|
|
780
781
|
"pan": "not-applicable"
|
|
781
782
|
},
|
|
@@ -924,7 +925,7 @@
|
|
|
924
925
|
],
|
|
925
926
|
"required": [
|
|
926
927
|
"date",
|
|
927
|
-
"
|
|
928
|
+
"title"
|
|
928
929
|
],
|
|
929
930
|
"optional": [
|
|
930
931
|
"status"
|
|
@@ -972,11 +973,11 @@
|
|
|
972
973
|
],
|
|
973
974
|
"required": [
|
|
974
975
|
"date",
|
|
975
|
-
"
|
|
976
|
+
"title"
|
|
976
977
|
],
|
|
977
978
|
"optional": [
|
|
978
|
-
"
|
|
979
|
-
"
|
|
979
|
+
"baselineDate",
|
|
980
|
+
"actualDate"
|
|
980
981
|
],
|
|
981
982
|
"interactions": [
|
|
982
983
|
"hover",
|
|
@@ -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
package/skills/ichartjs/SKILL.md
CHANGED
|
@@ -21,7 +21,7 @@ Recommended installation:
|
|
|
21
21
|
npx skills add wanghetommy/ichartjs --skill ichartjs
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Use `--agent codex --global --yes` for global non-interactive Codex installation. Use the tagged directory `https://github.com/wanghetommy/ichartjs/tree/v2.0.
|
|
24
|
+
Use `--agent codex --global --yes` for global non-interactive Codex installation. Use the tagged directory `https://github.com/wanghetommy/ichartjs/tree/v2.0.16/skills/ichartjs` when reproducibility matters. WorkBuddy can import the same directory through its Skill interface; do not assume a `--agent workbuddy` adapter unless the installed CLI declares it.
|
|
25
25
|
|
|
26
26
|
The Skill is a workflow adapter, not the chart runtime. If the current JavaScript or TypeScript project does not already depend on iChart.js, install the matching runtime from GitHub:
|
|
27
27
|
|
|
@@ -40,9 +40,9 @@ Do not install the unscoped npm registry package named `ichartjs`; it is current
|
|
|
40
40
|
5. Inspect the complete planning result, including `styleRecommendation`, warnings, and fallback status.
|
|
41
41
|
6. Stop when `requiredFields` is non-empty; request data or explain a supported alternative.
|
|
42
42
|
7. Build a JSON-serializable Spec using `suggestedEncodings`, the selected capability, and an applicable recipe.
|
|
43
|
-
8. Use chart-specific channels: Cartesian `x`/`y`, Pie/Funnel `category`/`value`, Gauge `value`, Heatmap `x`/`y`/`color`, and Radar `indicators[].field`. To load a recipe, use `import catalog from '@taylorwong/ichartjs/recipes/minimal-specs' with { type: 'json' }` and select `catalog.examples[type]
|
|
43
|
+
8. Use chart-specific channels: Cartesian `x`/`y`, Pie/Funnel `category`/`value`, Gauge `value`, Heatmap `x`/`y`/`color`, and Radar `indicators[].field`. Funnel stage text comes from `encoding.category` (default `name`) and is always rendered; `labels.enabled` additionally renders values. To load a recipe, use `import catalog from '@taylorwong/ichartjs/recipes/minimal-specs' with { type: 'json' }` and select `catalog.examples[type]`; `recipes/` is the public export path backed by the package's `agent-recipes/` directory.
|
|
44
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.
|
|
45
|
+
10. Call `validateSpec()` before rendering. Repair `UNSUPPORTED_ENCODING_CHANNEL`, `MISSING_ENCODING_FIELD`, `MISSING_GAUGE_DOMAIN`, `ZERO_TOTAL`, `UNKNOWN_INTENT`, misplaced-option, and unsupported-axis warnings before presenting the chart.
|
|
46
46
|
11. Call `createChart()` only after validation succeeds. Gauge Specs must declare a meaningful `domain`.
|
|
47
47
|
12. Self-check with `chart.explain()`, `chart.getState()`, `health.renderable`, and JSON export. Treat `VALUE_CLAMPED`, `LABELS_SUPPRESSED`, `NEGATIVE_VALUE_DROPPED`, and `ZERO_TOTAL` as material diagnostics to report.
|
|
48
48
|
13. Provide an exact preview URL or artifact path and report assumptions, warnings, and deferred checks.
|
|
@@ -75,6 +75,8 @@ Route by requested output:
|
|
|
75
75
|
- Treat `getCapabilities().intents` as an allowlist; never pass a natural-language sentence as `planChart().intent`.
|
|
76
76
|
- If planning returns `fallbackUsed: true`, use `intentSuggestions` to remap or ask for confirmation; never silently accept the fallback chart.
|
|
77
77
|
- Keep axis titles/formats under `xAxis`/`yAxis`, labels under `labels`, and legend settings under `legend`.
|
|
78
|
+
- Use `title: { text, subtitle }`; string titles and `title.label` are compatibility forms and are reported as normalizations.
|
|
79
|
+
- Use diagram `label`, edge `from`/`to`, and Gantt `dependencies`; do not substitute `name`, `source`/`target`, or `dependsOn`.
|
|
78
80
|
- 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
81
|
- Add stable string `id` values to tabular rows when lineage or linked updates are part of the deliverable.
|
|
80
82
|
- Avoid Pie for high-cardinality categories; prefer Bar for comparison.
|
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
|
|
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
|
|
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
|
|
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 =
|
|
149
|
-
? Math.max(p.left, p.
|
|
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 =
|
|
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
|
|
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) * .
|
|
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 = [],
|
|
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,15 +424,15 @@ 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.
|
|
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; });
|
|
409
431
|
} else if (spec.type === 'funnel') {
|
|
410
|
-
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
|
-
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 }));
|
|
432
|
+
const categoryField = spec.encoding.category?.field || 'name', 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;
|
|
433
|
+
data.rows.forEach((row, index) => { const value = Number(row[valueField]) || 0, category = String(row[categoryField] ?? row.name ?? row.id ?? `Stage ${index + 1}`), 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, category, value }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill }, dataRef, interactive: true })); const labelSize = fontSize(spec, 'label', 12), textColor = readableLabelColor(spec, fill), label = truncateText(category, Math.max(labelSize * 2, width - 12), labelSize); addText(scene, `funnel-category-${index}`, label, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 - (spec.labels?.enabled ? labelSize * 0.6 : 0), { fill: textColor, font: spec.labels?.font || font(spec, 'label'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }, dataRef); if (spec.labels?.enabled) addText(scene, `funnel-value-${index}`, formatValue(value, spec.labels.format, spec.locale), geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 + labelSize * 0.65, { fill: textColor, font: spec.labels?.font || font(spec, 'label'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }, dataRef); });
|
|
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.
|
|
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' });
|
|
@@ -24,11 +24,11 @@ export const chartDefinitions = {
|
|
|
24
24
|
scatter: { family: 'cartesian', intents: ['relationship', 'correlation'], required: ['measure', 'measure'], optional: ['dimension'], interactions: ['hover', 'tooltip', 'selection', 'brush', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'grid', 'labels', 'formatting'], ['stacked']) },
|
|
25
25
|
funnel: { family: 'stage', intents: ['funnel', 'conversion'], required: ['dimension', 'measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting'], ['axes', 'grid', 'zoom', 'pan']) },
|
|
26
26
|
gauge: { family: 'indicator', intents: ['progress', 'single-value'], required: ['measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting', 'domain'], ['axes', 'grid', 'legend', 'zoom', 'pan']) },
|
|
27
|
-
heatmap: { family: 'matrix', intents: ['matrix', 'correlation-grid'], required: ['dimension', 'dimension', 'measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, '
|
|
27
|
+
heatmap: { family: 'matrix', intents: ['matrix', 'correlation-grid'], required: ['dimension', 'dimension', 'measure'], optional: [], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'matrix-labels', 'labels', 'formatting', 'color-scale', 'missing-values'], ['axes', 'zoom', 'pan']) },
|
|
28
28
|
radar: { family: 'radial', intents: ['multidimensional', 'profile'], required: ['three-or-more-measures'], optional: ['series'], interactions: ['hover', 'tooltip', 'selection', 'keyboard'], features: status([...commonPresentation, 'legend', 'labels', 'formatting', 'multi-series', 'indicator-domains', 'mixed-unit-warning'], ['axes', 'grid', 'zoom', 'pan']) },
|
|
29
29
|
gantt: { family: 'project', intents: ['schedule', 'variance'], required: ['id', 'start', 'end'], optional: ['dependencies', 'progress', 'baseline', 'actual'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard', 'linked-filter', 'linked-selection'], features: status([...commonPresentation, 'labels', 'formatting', 'critical-path', 'slack', 'baseline-actual-variance', 'working-calendar', 'dependencies']) },
|
|
30
|
-
timeline: { family: 'project', intents: ['timeline', 'milestone'], required: ['date', '
|
|
31
|
-
milestone: { family: 'project', intents: ['milestone'], required: ['date', '
|
|
30
|
+
timeline: { family: 'project', intents: ['timeline', 'milestone'], required: ['date', 'title'], optional: ['status'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting']) },
|
|
31
|
+
milestone: { family: 'project', intents: ['milestone'], required: ['date', 'title'], optional: ['baselineDate', 'actualDate'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting', 'baseline-actual-variance']) },
|
|
32
32
|
burndown: { family: 'project', intents: ['progress', 'release'], required: ['date', 'remaining'], optional: ['ideal', 'scopeChange'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'axes', 'labels', 'formatting', 'scope-change', 'release-forecast']) },
|
|
33
33
|
flow: { family: 'diagram', intents: ['workflow'], required: ['nodes'], optional: ['edges', 'groups', 'ports'], interactions: ['hover', 'tooltip', 'selection', 'edge-selection', 'drag', 'edgeDrag', 'zoom', 'pan', 'keyboard', 'keyboard-edit', 'keyboard-port-connect', 'portConnect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'manual-routing', 'layout', 'groups', 'ports', 'history'], ['axes', 'grid', 'legend']) },
|
|
34
34
|
swimlane: { family: 'diagram', intents: ['responsibility'], required: ['nodes', 'lanes'], optional: ['edges', 'groups', 'ports'], interactions: ['hover', 'tooltip', 'selection', 'edge-selection', 'drag', 'edgeDrag', 'zoom', 'pan', 'keyboard', 'keyboard-edit', 'keyboard-port-connect', 'portConnect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'manual-routing', 'layout', 'groups', 'ports', 'lanes', 'history'], ['axes', 'grid', 'legend']) },
|
package/src/diagram.mjs
CHANGED
|
@@ -28,14 +28,17 @@ export function normalizeDiagramSpec(spec = {}) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export function validateDiagram(spec = {}) {
|
|
31
|
-
const errors = [], normalized = normalizeDiagramSpec(spec), nodes = normalized.nodes, edges = normalized.edges, lanes = normalized.lanes, layers = normalized.layers;
|
|
31
|
+
const errors = [], warnings = [], normalized = normalizeDiagramSpec(spec), nodes = normalized.nodes, edges = normalized.edges, lanes = normalized.lanes, layers = normalized.layers;
|
|
32
32
|
const nodeIds = new Set(), laneIds = new Set(lanes.map(lane => lane.id));
|
|
33
33
|
const groupIds = new Set();
|
|
34
34
|
const layerIds = new Set(layers.map(layer => layer.id));
|
|
35
35
|
if (!diagramModes.includes(normalized.diagram.mode)) errors.push({ code: 'DIAGRAM_MODE', path: 'diagram.mode', message: `Unsupported diagram mode: ${normalized.diagram.mode}.`, suggestion: `Use ${diagramModes.join(', ')}.` });
|
|
36
36
|
layers.forEach((layer, index) => {
|
|
37
37
|
if (typeof layer?.id !== 'string' || !layer.id) errors.push({ code: 'LAYER_ID', path: `layers.${index}.id`, message: 'Architecture layers require stable IDs.' });
|
|
38
|
+
if (layer?.name !== undefined && layer?.label === undefined) warnings.push({ code: 'UNSUPPORTED_DIAGRAM_LABEL_FIELD', path: `layers.${index}.name`, message: 'Architecture layer display text uses label, not name.', suggestion: 'Rename layers[].name to layers[].label.' });
|
|
38
39
|
});
|
|
40
|
+
normalized.groups.forEach((group, index) => { if (group?.name !== undefined && group?.label === undefined) warnings.push({ code: 'UNSUPPORTED_DIAGRAM_LABEL_FIELD', path: `groups.${index}.name`, message: 'Group display text uses label, not name.', suggestion: 'Rename groups[].name to groups[].label.' }); });
|
|
41
|
+
normalized.boundaries.forEach((boundary, index) => { if (boundary?.name !== undefined && boundary?.label === undefined) warnings.push({ code: 'UNSUPPORTED_DIAGRAM_LABEL_FIELD', path: `boundaries.${index}.name`, message: 'Boundary display text uses label, not name.', suggestion: 'Rename boundaries[].name to boundaries[].label.' }); });
|
|
39
42
|
normalized.groups.forEach((group, index) => {
|
|
40
43
|
if (typeof group?.id !== 'string' || !group.id) errors.push({ code: 'GROUP_ID', path: `groups.${index}.id`, message: 'Groups require stable IDs.' });
|
|
41
44
|
else if (groupIds.has(group.id)) errors.push({ code: 'DUPLICATE_GROUP_ID', path: `groups.${index}.id`, message: 'Group IDs must be unique.' });
|
|
@@ -46,6 +49,7 @@ export function validateDiagram(spec = {}) {
|
|
|
46
49
|
if (typeof node.id !== 'string' || !node.id) errors.push({ code: 'NODE_ID', path: `nodes.${index}.id`, message: 'Diagram nodes require stable string IDs.' });
|
|
47
50
|
else if (nodeIds.has(node.id)) errors.push({ code: 'DUPLICATE_NODE_ID', path: `nodes.${index}.id`, message: `Duplicate node ID: ${node.id}.` });
|
|
48
51
|
else nodeIds.add(node.id);
|
|
52
|
+
if (node?.name !== undefined && node?.label === undefined) warnings.push({ code: 'UNSUPPORTED_DIAGRAM_LABEL_FIELD', path: `nodes.${index}.name`, message: 'Node display text uses label, not name.', suggestion: 'Rename nodes[].name to nodes[].label.' });
|
|
49
53
|
if (node.position && (!finite(node.position.x) || !finite(node.position.y))) errors.push({ code: 'NODE_POSITION', path: `nodes.${index}.position`, message: 'Node positions must contain finite x and y.' });
|
|
50
54
|
if (node.laneId && lanes.length && !laneIds.has(node.laneId)) errors.push({ code: 'MISSING_LANE', path: `nodes.${index}.laneId`, message: `Unknown lane ID: ${node.laneId}.` });
|
|
51
55
|
if (node.size && (!finite(node.size.width) || !finite(node.size.height) || node.size.width <= 0 || node.size.height <= 0)) errors.push({ code: 'NODE_SIZE', path: `nodes.${index}.size`, message: 'Node sizes must be finite and positive.' });
|
|
@@ -68,8 +72,12 @@ export function validateDiagram(spec = {}) {
|
|
|
68
72
|
edges.forEach((edge, index) => {
|
|
69
73
|
if (typeof edge.id === 'string' && edgeIds.has(edge.id)) errors.push({ code: 'DUPLICATE_EDGE_ID', path: `edges.${index}.id`, message: `Duplicate edge ID: ${edge.id}.` });
|
|
70
74
|
if (edge.id) edgeIds.add(edge.id);
|
|
71
|
-
|
|
72
|
-
if (
|
|
75
|
+
const usesUnsupportedEndpointNames = (edge.from === undefined || edge.to === undefined) && (edge.source !== undefined || edge.target !== undefined);
|
|
76
|
+
if (usesUnsupportedEndpointNames) errors.push({ code: 'UNSUPPORTED_EDGE_ENDPOINT_FIELDS', path: `edges.${index}`, message: 'Diagram edges use from and to, not source and target.', suggestion: 'Rename edge.source to edge.from and edge.target to edge.to.' });
|
|
77
|
+
else {
|
|
78
|
+
if (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)) errors.push({ code: 'EDGE_ENDPOINT', path: `edges.${index}`, message: 'Edges must reference existing node IDs.' });
|
|
79
|
+
if (edge.from === edge.to) errors.push({ code: 'SELF_EDGE', path: `edges.${index}`, message: 'Self-referencing edges are not allowed by default.' });
|
|
80
|
+
}
|
|
73
81
|
['fromPort', 'toPort'].forEach(key => {
|
|
74
82
|
const node = nodes.find(item => item.id === edge[key === 'fromPort' ? 'from' : 'to']);
|
|
75
83
|
if (edge[key] && (!Array.isArray(node?.ports) || !node.ports.some(port => port?.id === edge[key]))) errors.push({ code: 'MISSING_PORT', path: `edges.${index}.${key}`, message: `Unknown port ${edge[key]}.` });
|
|
@@ -85,7 +93,7 @@ export function validateDiagram(spec = {}) {
|
|
|
85
93
|
const parents = new Map(nodes.map(node => [node.id, node.parentId]).filter(([, parentId]) => parentId));
|
|
86
94
|
nodes.forEach(node => { const seen = new Set([node.id]); let current = node.parentId; while (current) { if (seen.has(current)) { errors.push({ code: 'MINDMAP_CYCLE', path: `nodes.${node.id}.parentId`, message: 'Mindmap parent relationships must be acyclic.' }); break; } seen.add(current); current = parents.get(current); } });
|
|
87
95
|
}
|
|
88
|
-
return { valid: errors.length === 0, errors, spec: normalized };
|
|
96
|
+
return { valid: errors.length === 0, errors, warnings, spec: normalized };
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
export function diagramGraph(spec) {
|
package/src/index.mjs
CHANGED
|
@@ -334,7 +334,7 @@ export class Chart {
|
|
|
334
334
|
_touchDistance(event) { if (!event.touches || event.touches.length < 2) return null; const [first, second] = event.touches; return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY); }
|
|
335
335
|
_eventPoint(event, target) { const rect = target.getBoundingClientRect(); return { x: (event.clientX - rect.left) * this.spec.width / (rect.width || this.spec.width), y: (event.clientY - rect.top) * this.spec.height / (rect.height || this.spec.height) }; }
|
|
336
336
|
_updateCrosshair(x, y) { const vertical = this.model.scene.find('crosshair-x'), horizontal = this.model.scene.find('crosshair-y'); if (!vertical || !horizontal) return; vertical.geometry.x1 = vertical.geometry.x2 = x; vertical.style.opacity = 0.7; horizontal.geometry.y1 = horizontal.geometry.y2 = y; horizontal.style.opacity = 0.7; this.renderer.render(this.model.scene); }
|
|
337
|
-
_showTooltip(payload) { if (typeof document === 'undefined' || !this.container || !payload.datum) return; let tip = this._tooltip; if (!tip) { tip = this._tooltip = document.createElement('div'); tip.className = 'ichart-v2-tooltip'; Object.assign(tip.style, { position: 'fixed', pointerEvents: 'none', zIndex: 9999, padding: '8px 10px', borderRadius: '6px', maxWidth: 'min(320px, calc(100vw - 24px))', whiteSpace: 'pre-line' }); document.body.appendChild(tip); } Object.assign(tip.style, { background: this.spec.theme.surface, color: this.spec.theme.text, border: `1px solid ${this.spec.theme.border}`, font: this.spec.theme.typography.tooltip.font, boxShadow: `0 4px 12px ${this.spec.theme.border}88` }); const text = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(this.spec.type) ? projectTooltip(this.spec.type, payload.datum) : Object.entries(payload.datum).map(([key, value]) => `${key}: ${value}`).join(' · '); tip.textContent = text; const left = Math.min((payload.nativeEvent.clientX || 0) + 12, window.innerWidth - tip.offsetWidth - 12); const top = Math.min((payload.nativeEvent.clientY || 0) + 12, window.innerHeight - tip.offsetHeight - 12); tip.style.left = `${Math.max(12, left)}px`; tip.style.top = `${Math.max(12, top)}px`; }
|
|
337
|
+
_showTooltip(payload) { if (typeof document === 'undefined' || !this.container || !payload.datum) return; let tip = this._tooltip; if (!tip) { tip = this._tooltip = document.createElement('div'); tip.className = 'ichart-v2-tooltip'; Object.assign(tip.style, { position: 'fixed', pointerEvents: 'none', zIndex: 9999, padding: '8px 10px', borderRadius: '6px', maxWidth: 'min(320px, calc(100vw - 24px))', whiteSpace: 'pre-line' }); document.body.appendChild(tip); } Object.assign(tip.style, { background: this.spec.theme.surface, color: this.spec.theme.text, border: `1px solid ${this.spec.theme.border}`, font: this.spec.theme.typography.tooltip.font, boxShadow: `0 4px 12px ${this.spec.theme.border}88` }); const text = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(this.spec.type) ? projectTooltip(this.spec.type, payload.datum, this.spec.locale) : Object.entries(payload.datum).map(([key, value]) => `${key}: ${value}`).join(' · '); tip.textContent = text; const left = Math.min((payload.nativeEvent.clientX || 0) + 12, window.innerWidth - tip.offsetWidth - 12); const top = Math.min((payload.nativeEvent.clientY || 0) + 12, window.innerHeight - tip.offsetHeight - 12); tip.style.left = `${Math.max(12, left)}px`; tip.style.top = `${Math.max(12, top)}px`; }
|
|
338
338
|
_hideTooltip() { if (this._tooltip) this._tooltip.style.left = '-10000px'; }
|
|
339
339
|
_validateMutation(operation, input) {
|
|
340
340
|
const result = validateSpec(input);
|
|
@@ -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.
|
|
774
|
+
export const iChart = { version: '2.0.16', 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 };
|
package/src/project.mjs
CHANGED
|
@@ -13,7 +13,45 @@ import { axisLabelLayout, estimateTextWidth, fontSize, titleLayout, truncateText
|
|
|
13
13
|
export const projectTypes = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'];
|
|
14
14
|
const day = 86400000;
|
|
15
15
|
export const timestamp = value => value == null || value === '' ? NaN : new Date(value).getTime();
|
|
16
|
-
const
|
|
16
|
+
const projectMessages = locale => String(locale || 'en-US').toLowerCase().startsWith('zh') ? {
|
|
17
|
+
estimatedFinish: '预计完成',
|
|
18
|
+
forecastUnavailable: '无法预测',
|
|
19
|
+
date: '日期', remaining: '剩余', scopeChange: '范围变更', totalScope: '范围总量', completed: '已完成',
|
|
20
|
+
forecast: '预测', parent: '父主题', rootTopic: '根主题', branch: '分支', layer: '层', boundary: '边界', role: '角色',
|
|
21
|
+
progress: '进度', status: '状态', owner: '负责人', dependsOn: '依赖', critical: '关键路径', float: '浮动时间', variance: '偏差', lane: '泳道', days: '天', yes: '是', no: '否', unavailable: '不可用'
|
|
22
|
+
} : {
|
|
23
|
+
estimatedFinish: 'Estimated finish',
|
|
24
|
+
forecastUnavailable: 'Forecast unavailable',
|
|
25
|
+
date: 'Date', remaining: 'Remaining', scopeChange: 'Scope change', totalScope: 'Total scope', completed: 'Completed',
|
|
26
|
+
forecast: 'Forecast', parent: 'Parent', rootTopic: 'Root topic', branch: 'Branch', layer: 'Layer', boundary: 'Boundary', role: 'Role',
|
|
27
|
+
progress: 'Progress', status: 'Status', owner: 'Owner', dependsOn: 'Depends on', critical: 'Critical', float: 'Float', variance: 'Variance', lane: 'Lane', days: 'days', yes: 'yes', no: 'no', unavailable: 'unavailable'
|
|
28
|
+
};
|
|
29
|
+
const dateLabel = (value, locale = 'en-US') => {
|
|
30
|
+
try {
|
|
31
|
+
return new Intl.DateTimeFormat(locale, { timeZone: 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(value));
|
|
32
|
+
} catch {
|
|
33
|
+
return new Date(value).toISOString().slice(0, 10);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const projectTickCount = width => Math.max(2, Math.min(6, Math.floor(width / 100)));
|
|
37
|
+
function projectTickValues(min, max, count = 5) {
|
|
38
|
+
return Array.from({ length: count }, (_, index) => min + (max - min) * index / Math.max(1, count - 1));
|
|
39
|
+
}
|
|
40
|
+
function projectTickLabels(min, max, width, locale = 'en-US') {
|
|
41
|
+
return projectTickValues(min, max, projectTickCount(width)).map(value => dateLabel(value, locale));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function projectDateDomain(spec, rows) {
|
|
45
|
+
const values = spec.type === 'gantt' ? rows.flatMap(rowWindow) : rows.map(row => timestamp(row?.date ?? row?.start ?? row?.end)).filter(Number.isFinite);
|
|
46
|
+
let min = Math.min(...(values.length ? values : [Date.now()])), max = Math.max(...(values.length ? values : [Date.now() + day]));
|
|
47
|
+
if (min === max) { min -= day; max += day; }
|
|
48
|
+
if (['timeline', 'milestone'].includes(spec.type)) {
|
|
49
|
+
const padding = Math.max(day, (max - min) * 0.03);
|
|
50
|
+
min -= padding;
|
|
51
|
+
max += padding;
|
|
52
|
+
}
|
|
53
|
+
return [min, max];
|
|
54
|
+
}
|
|
17
55
|
const dependencyId = dependency => typeof dependency === 'string' ? dependency : dependency?.id || null;
|
|
18
56
|
const dependencyType = dependency => typeof dependency === 'object' && dependency?.type ? dependency.type : 'finish-to-start';
|
|
19
57
|
const markText = (theme, background) => (contrastRatio(theme.text, background) || 0) >= (contrastRatio(theme.background, background) || 0) ? theme.text : theme.background;
|
|
@@ -58,15 +96,16 @@ function arrow(scene, id, points, reference, critical = false, curve = null) {
|
|
|
58
96
|
scene.add({ id: id.startsWith('dependency-') ? id.replace('dependency-', 'dependency-arrow-') : `${id}-arrow`, type: 'path', geometry: { points: [tip, { x: tip.x - size * Math.cos(angle - Math.PI / 6), y: tip.y - size * Math.sin(angle - Math.PI / 6) }, { x: tip.x - size * Math.cos(angle + Math.PI / 6), y: tip.y - size * Math.sin(angle + Math.PI / 6) }, tip] }, style: { fill: color, stroke: color }, zIndex: 3 });
|
|
59
97
|
}
|
|
60
98
|
|
|
61
|
-
function timeAxis(scene, plot, min, max) {
|
|
99
|
+
function timeAxis(scene, plot, min, max, locale = 'en-US') {
|
|
62
100
|
const map = value => plot.x + (timestamp(value) - min) / (max - min || day) * plot.width;
|
|
63
|
-
const ticks =
|
|
64
|
-
const labelLayout = scene.xLabelLayout || axisLabelLayout({ theme: scene.theme },
|
|
65
|
-
|
|
66
|
-
|
|
101
|
+
const ticks = projectTickCount(plot.width), values = projectTickValues(min, max, ticks), labels = values.map(value => dateLabel(value, locale));
|
|
102
|
+
const labelLayout = scene.xLabelLayout || axisLabelLayout({ theme: scene.theme }, labels, plot.width);
|
|
103
|
+
scene.xLabelLayout = labelLayout;
|
|
104
|
+
values.forEach((time, index) => {
|
|
105
|
+
const x = map(time);
|
|
67
106
|
scene.add({ id: `project-grid-${index}`, type: 'line', geometry: { x1: x, y1: plot.y, x2: x, y2: plot.y + plot.height }, style: { stroke: scene.theme?.grid || '#e2e8f0' } });
|
|
68
|
-
text(scene, `project-tick-${index}`, dateLabel(time), x, plot.y + plot.height + (labelLayout.rotation ? 10 : 22), { fill: scene.theme?.muted, textAnchor: labelLayout.rotation ? 'end' : index === 0 ? 'start' : index === ticks - 1 ? 'end' : 'middle', textBaseline: labelLayout.rotation ? 'middle' : 'alphabetic', rotation: labelLayout.rotation, font: scene.theme?.typography?.axis?.font || '11px system-ui' });
|
|
69
|
-
}
|
|
107
|
+
text(scene, `project-tick-${index}`, dateLabel(time, locale), x, plot.y + plot.height + (labelLayout.rotation ? 10 : 22), { fill: scene.theme?.muted, textAnchor: labelLayout.rotation ? 'end' : index === 0 ? 'start' : index === ticks - 1 ? 'end' : 'middle', textBaseline: labelLayout.rotation ? 'middle' : 'alphabetic', rotation: labelLayout.rotation, font: scene.theme?.typography?.axis?.font || '11px system-ui' });
|
|
108
|
+
});
|
|
70
109
|
return map;
|
|
71
110
|
}
|
|
72
111
|
|
|
@@ -161,14 +200,13 @@ export function analyzeBurndown(rows, options = {}) {
|
|
|
161
200
|
function tasksScene(scene, spec, rows, state) {
|
|
162
201
|
const plot = state.plot;
|
|
163
202
|
const overlays = projectOverlays(spec);
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
if (min === max) { min -= day; max += day; }
|
|
167
|
-
const map = timeAxis(scene, plot, min, max), positions = new Map();
|
|
203
|
+
const [min, max] = projectDateDomain(spec, rows);
|
|
204
|
+
const map = timeAxis(scene, plot, min, max, spec.locale), positions = new Map();
|
|
168
205
|
const analyticsMap = new Map((state.schedule?.tasks || []).map(task => [task.id, task]));
|
|
169
206
|
const highlighted = new Set(spec.criticalPath === false || !overlays.criticalPath ? [] : Array.isArray(spec.criticalPath) ? spec.criticalPath : state.schedule?.criticalIds || []);
|
|
170
207
|
const selected = projectSelection(spec, state);
|
|
171
|
-
const rowHeight = plot.height / Math.max(1, rows.length), barHeight = Math.min(20, Math.max(10, rowHeight * 0.62)), barOffset = barHeight / 2;
|
|
208
|
+
const rowHeight = plot.height / Math.max(1, rows.length), barHeight = Math.min(20, Math.max(10, rowHeight * 0.62)), barOffset = barHeight / 2, isEventTimeline = ['timeline', 'milestone'].includes(spec.type), eventRadius = Math.min(7, Math.max(3, rowHeight * 0.28)), placedEvents = [];
|
|
209
|
+
let collisionsResolved = 0, collisionsUnresolved = 0;
|
|
172
210
|
rows.forEach((row, index) => {
|
|
173
211
|
const analytics = analyticsMap.get(row.id);
|
|
174
212
|
const recordId = linkedRecordId(row, index);
|
|
@@ -179,7 +217,14 @@ function tasksScene(scene, spec, rows, state) {
|
|
|
179
217
|
const actualStart = row.actualStart || row.actualDate || null;
|
|
180
218
|
const actualEnd = row.actualEnd || row.actualDate || actualStart;
|
|
181
219
|
const start = map(startValue), end = map(endValue);
|
|
182
|
-
const
|
|
220
|
+
const baseY = plot.y + (index + 0.5) * rowHeight;
|
|
221
|
+
const markerX = map(startValue);
|
|
222
|
+
const eventOffsets = [0, eventRadius * 1.5, -eventRadius * 1.5, eventRadius * 3, -eventRadius * 3];
|
|
223
|
+
const eventY = isEventTimeline ? eventOffsets.map(offset => Math.max(plot.y + eventRadius, Math.min(plot.y + plot.height - eventRadius, baseY + offset))).find(candidate => !placedEvents.some(event => Math.abs(event.x - markerX) < eventRadius * 2 + 4 && Math.abs(event.y - candidate) < eventRadius * 2 + 4)) : null;
|
|
224
|
+
if (isEventTimeline && eventY == null) collisionsUnresolved += 1;
|
|
225
|
+
const y = isEventTimeline ? eventY ?? baseY : baseY;
|
|
226
|
+
if (isEventTimeline && y !== baseY) collisionsResolved += 1;
|
|
227
|
+
if (isEventTimeline) placedEvents.push({ x: markerX, y });
|
|
183
228
|
const milestone = spec.type === 'milestone' || row.milestone || start === end;
|
|
184
229
|
if (overlays.baseline && baselineStart) {
|
|
185
230
|
const baselineStartX = map(baselineStart), baselineEndX = map(baselineEnd || baselineStart);
|
|
@@ -191,7 +236,7 @@ function tasksScene(scene, spec, rows, state) {
|
|
|
191
236
|
if (milestone) scene.add({ id: `project-actual-${index}`, type: 'circle', geometry: { cx: actualStartX, cy: y, r: 3 }, bounds: { x: actualStartX - 3, y: y - 3, width: 6, height: 6 }, style: { fill: spec.theme.text }, zIndex: 3 });
|
|
192
237
|
else scene.add({ id: `project-actual-${index}`, type: 'rect', geometry: { x: actualStartX, y: y + barOffset + 2, width: Math.max(2, actualEndX - actualStartX), height: 3 }, style: { fill: spec.theme.text, opacity: 0.35 }, zIndex: 3 });
|
|
193
238
|
}
|
|
194
|
-
const geometry = milestone ? { cx: start, cy: y, r:
|
|
239
|
+
const geometry = milestone ? { cx: start, cy: y, r: eventRadius } : { x: start, y: y - barOffset, width: Math.max(2, end - start), height: barHeight };
|
|
195
240
|
const bounds = milestone ? { x: start - 8, y: y - 8, width: 16, height: 16 } : { ...geometry };
|
|
196
241
|
const datum = taskDatum(row, analytics, recordId);
|
|
197
242
|
scene.add({
|
|
@@ -210,7 +255,8 @@ function tasksScene(scene, spec, rows, state) {
|
|
|
210
255
|
});
|
|
211
256
|
positions.set(row.id || recordId, { start, end, y });
|
|
212
257
|
const label = row.name || row.title || row.label || row.id || `Item ${index + 1}`;
|
|
213
|
-
|
|
258
|
+
const labelSize = Math.max(9, Math.min(fontSize(spec, 'axis', 12), rowHeight * 0.45));
|
|
259
|
+
text(scene, `project-label-${index}`, truncateText(label, Math.max(labelSize, plot.x - 20), labelSize), plot.x - 12, y + 4, { textAnchor: 'end', font: `${labelSize}px system-ui` });
|
|
214
260
|
if (row.progress != null && !milestone) scene.add({ id: `project-progress-${index}`, type: 'rect', geometry: { ...geometry, width: geometry.width * (row.progress > 1 ? row.progress / 100 : row.progress) }, style: { fill: spec.theme.text, opacity: 0.25 }, zIndex: 3 });
|
|
215
261
|
if (overlays.slack && analytics?.latestFinish && !milestone) {
|
|
216
262
|
const latestEnd = map(analytics.latestFinish);
|
|
@@ -221,6 +267,7 @@ function tasksScene(scene, spec, rows, state) {
|
|
|
221
267
|
if (variance != null) text(scene, `project-variance-${index}`, `${variance > 0 ? '+' : ''}${variance}d`, milestone ? start + 12 : end + 8, y - 12, { font: spec.theme.typography.axis.font, fill: variance > 0 ? spec.theme.status.danger : variance < 0 ? spec.theme.status.info : spec.theme.muted });
|
|
222
268
|
}
|
|
223
269
|
});
|
|
270
|
+
if (isEventTimeline) state.eventLayout = { collisionsResolved, collisionsUnresolved, rows: rows.length };
|
|
224
271
|
rows.forEach((row, index) => (row.dependencies || []).forEach((dependency, dependencyIndex) => {
|
|
225
272
|
const fromId = dependencyId(dependency), toId = row.id;
|
|
226
273
|
const from = positions.get(fromId), to = positions.get(toId);
|
|
@@ -239,7 +286,7 @@ function burndownScene(scene, spec, rows, state) {
|
|
|
239
286
|
state.projectAnalytics = { ...(state.projectAnalytics || {}), burndown: analysis };
|
|
240
287
|
const min = Math.min(analysis.start, samples[0]?.time || analysis.start || Date.now());
|
|
241
288
|
const max = Math.max(analysis.end, samples.at(-1)?.time || analysis.end || min + day, forecast.time || 0, min + day);
|
|
242
|
-
const map = timeAxis(scene, plot, min, max);
|
|
289
|
+
const map = timeAxis(scene, plot, min, max, spec.locale);
|
|
243
290
|
const maxValue = Math.max(1, ...samples.flatMap(sample => [sample.remaining, sample.ideal, sample.scope]));
|
|
244
291
|
const mapY = value => plot.y + plot.height - value / maxValue * plot.height;
|
|
245
292
|
const selected = projectSelection(spec, state);
|
|
@@ -256,7 +303,8 @@ function burndownScene(scene, spec, rows, state) {
|
|
|
256
303
|
const last = samples.at(-1);
|
|
257
304
|
scene.add({ id: 'burndown-forecast', type: 'path', geometry: { points: [{ x: map(last.time), y: mapY(last.remaining) }, { x: map(forecast.time), y: mapY(0) }] }, style: { fill: 'none', stroke: spec.theme.status.danger, strokeWidth: 2 } });
|
|
258
305
|
}
|
|
259
|
-
|
|
306
|
+
const messages = projectMessages(spec.locale);
|
|
307
|
+
text(scene, 'burndown-projected', forecast.date ? `${messages.estimatedFinish}: ${forecast.date}` : `${messages.forecastUnavailable}: ${forecast.reason}`, plot.x, plot.y - 16, { font: spec.theme.typography.axis.font });
|
|
260
308
|
}
|
|
261
309
|
|
|
262
310
|
function diagramScene(scene, spec, rows, state) {
|
|
@@ -373,7 +421,7 @@ export function buildProjectScene(spec) {
|
|
|
373
421
|
const left = spec.type === 'architecture' ? architectureLabelReserve(spec) : ['gantt', 'timeline', 'milestone', 'swimlane'].includes(spec.type) ? projectLabelReserve(spec, sourceRows) : spec.padding.left;
|
|
374
422
|
const title = titleLayout(spec);
|
|
375
423
|
const plotTop = Math.max(spec.padding.top, title.bottom + (title.bottom ? 12 : 0)) + (spec.type === 'burndown' ? 16 : 0);
|
|
376
|
-
const plotWidth = Math.max(1, spec.width - left - spec.padding.right), xLabels = axisLabelLayout(spec,
|
|
424
|
+
const plotWidth = Math.max(1, spec.width - left - spec.padding.right), dateDomain = projectDateDomain(spec, sourceRows), xLabels = axisLabelLayout(spec, projectTickLabels(dateDomain[0], dateDomain[1], plotWidth, spec.locale), plotWidth);
|
|
377
425
|
const bottomReserve = Math.max(spec.padding.bottom + 16, Math.ceil(16 + xLabels.projectedHeight));
|
|
378
426
|
const state = { plot: { x: left, y: plotTop, width: plotWidth, height: Math.max(1, spec.height - plotTop - bottomReserve) }, xLabels, compact: spec.width < 360 || spec.height < 240, recommendedSize: { minWidth: 280, minHeight: 220 }, linked, projectAnalytics: { linked } };
|
|
379
427
|
scene.xLabelLayout = xLabels;
|
|
@@ -387,6 +435,11 @@ export function buildProjectScene(spec) {
|
|
|
387
435
|
else if (diagram) diagramScene(scene, spec, data.rows, state);
|
|
388
436
|
else if (spec.type === 'burndown') burndownScene(scene, spec, data.rows, state);
|
|
389
437
|
else tasksScene(scene, spec, data.rows, state);
|
|
438
|
+
if (state.eventLayout?.collisionsUnresolved) data.warnings.push({
|
|
439
|
+
code: 'TIMELINE_COLLISION',
|
|
440
|
+
message: 'Timeline or milestone markers could not be fully separated in the available space.',
|
|
441
|
+
suggestion: 'Increase the chart height or reduce the number of events.'
|
|
442
|
+
});
|
|
390
443
|
const view = projectView(spec), mapX = value => value * view.scale + view.offsetX, mapY = value => value * view.scale + view.offsetY;
|
|
391
444
|
scene.walk(node => {
|
|
392
445
|
const geometry = node.geometry;
|
|
@@ -402,30 +455,31 @@ export function buildProjectScene(spec) {
|
|
|
402
455
|
return { scene, data, state };
|
|
403
456
|
}
|
|
404
457
|
|
|
405
|
-
export function projectTooltip(type, row) {
|
|
458
|
+
export function projectTooltip(type, row, locale = 'en-US') {
|
|
406
459
|
if (!row) return '';
|
|
460
|
+
const messages = projectMessages(locale);
|
|
407
461
|
if (type === 'burndown') return [
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
row.scope != null ?
|
|
412
|
-
row.completed != null ?
|
|
413
|
-
row.forecast ?
|
|
462
|
+
`${messages.date}: ${row.date}`,
|
|
463
|
+
`${messages.remaining}: ${row.remaining ?? row.actual ?? row.value}`,
|
|
464
|
+
`${messages.scopeChange}: ${row.scopeChange || 0}`,
|
|
465
|
+
row.scope != null ? `${messages.totalScope}: ${row.scope}` : null,
|
|
466
|
+
row.completed != null ? `${messages.completed}: ${row.completed}` : null,
|
|
467
|
+
row.forecast ? `${messages.estimatedFinish}: ${row.forecast}` : `${messages.forecast}: ${row.forecastReason || messages.unavailable}`
|
|
414
468
|
].filter(Boolean).join('\n');
|
|
415
469
|
const dependencies = (row.dependencies || []).map(dependencyId).filter(Boolean);
|
|
416
470
|
const variance = row.endVarianceDays ?? row.baselineVarianceDays ?? null;
|
|
417
|
-
if (type === 'mindmap') return [row.label || row.id, row.parentId ?
|
|
418
|
-
if (type === 'architecture') return [row.label || row.id, row.layerId ?
|
|
471
|
+
if (type === 'mindmap') return [row.label || row.id, row.parentId ? `${messages.parent}: ${row.parentId}` : messages.rootTopic, row.branch ? `${messages.branch}: ${row.branch}` : null, row.description].filter(value => value != null && value !== '').join('\n');
|
|
472
|
+
if (type === 'architecture') return [row.label || row.id, row.layerId ? `${messages.layer}: ${row.layerId}` : null, row.boundaryId ? `${messages.boundary}: ${row.boundaryId}` : null, row.role ? `${messages.role}: ${row.role}` : null, row.description].filter(value => value != null && value !== '').join('\n');
|
|
419
473
|
return [
|
|
420
474
|
row.name || row.title || row.label || row.id,
|
|
421
475
|
row.start ? `${row.start} → ${row.end || row.start}` : row.date,
|
|
422
|
-
row.progress != null ?
|
|
423
|
-
row.status ?
|
|
424
|
-
row.owner || row.resource ?
|
|
425
|
-
dependencies.length ?
|
|
426
|
-
row.critical != null ?
|
|
427
|
-
variance != null ?
|
|
428
|
-
row.laneId ?
|
|
476
|
+
row.progress != null ? `${messages.progress}: ${row.progress <= 1 ? Math.round(row.progress * 100) : row.progress}%` : null,
|
|
477
|
+
row.status ? `${messages.status}: ${row.status}` : null,
|
|
478
|
+
row.owner || row.resource ? `${messages.owner}: ${row.owner || row.resource}` : null,
|
|
479
|
+
dependencies.length ? `${messages.dependsOn}: ${dependencies.join(', ')}` : null,
|
|
480
|
+
row.critical != null ? `${messages.critical}: ${row.critical ? messages.yes : messages.no} · ${messages.float}: ${row.float ?? row.slack ?? 0} ${messages.days}` : null,
|
|
481
|
+
variance != null ? `${messages.variance}: ${variance > 0 ? '+' : ''}${variance} ${messages.days}` : null,
|
|
482
|
+
row.laneId ? `${messages.lane}: ${row.laneId}` : null,
|
|
429
483
|
row.description
|
|
430
484
|
].filter(value => value != null && value !== '').join('\n');
|
|
431
485
|
}
|
package/src/spec.mjs
CHANGED
|
@@ -43,17 +43,31 @@ function merge(base, extra) {
|
|
|
43
43
|
return result;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function normalizeTitle(title) {
|
|
47
|
+
if (typeof title === 'string') return { text: title };
|
|
48
|
+
if (title && typeof title === 'object' && !Array.isArray(title) && !title.text && typeof title.label === 'string') {
|
|
49
|
+
const normalized = clone(title);
|
|
50
|
+
normalized.text = normalized.label;
|
|
51
|
+
delete normalized.label;
|
|
52
|
+
return normalized;
|
|
53
|
+
}
|
|
54
|
+
return title;
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
function dependencyId(dependency) {
|
|
47
58
|
return typeof dependency === 'string' ? dependency : dependency && typeof dependency === 'object' && typeof dependency.id === 'string' ? dependency.id : null;
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
function dependencyErrors(rows) {
|
|
51
|
-
const errors = [], ids = new Set(rows.map(row => row.id).filter(Boolean)), graph = new Map(rows.map(row => [row.id, (row.dependencies
|
|
52
|
-
rows.forEach((row, index) =>
|
|
62
|
+
const errors = [], ids = new Set(rows.map(row => row.id).filter(Boolean)), graph = new Map(rows.map(row => [row.id, (Array.isArray(row.dependencies) ? row.dependencies : []).map(dependencyId).filter(Boolean)]));
|
|
63
|
+
rows.forEach((row, index) => {
|
|
64
|
+
if (row?.dependencies !== undefined && !Array.isArray(row.dependencies)) errors.push({ code: 'INVALID_DEPENDENCY_LIST', path: `data.values[${index}].dependencies`, message: 'Dependencies must be an array of task IDs or dependency objects.', suggestion: 'Use dependencies: ["task-id"] or [{ id, type, lag, lead }].' });
|
|
65
|
+
(Array.isArray(row?.dependencies) ? row.dependencies : []).forEach((dependency, dependencyIndex) => {
|
|
53
66
|
const id = dependencyId(dependency);
|
|
54
67
|
if (!id) errors.push({ code: 'INVALID_DEPENDENCY', path: `data.values[${index}].dependencies[${dependencyIndex}]`, message: 'Dependency must be a task id or an object with an id.', suggestion: 'Use a string id or { id, type, lag, lead }.' });
|
|
55
68
|
else if (!ids.has(id)) errors.push({ code: 'MISSING_DEPENDENCY', path: `data.values[${index}].dependencies`, message: `Task ${row.id || index} references missing task ${id}.`, suggestion: 'Use an existing task id.' });
|
|
56
|
-
|
|
69
|
+
});
|
|
70
|
+
});
|
|
57
71
|
const visiting = new Set(), visited = new Set();
|
|
58
72
|
function visit(id) { if (visiting.has(id)) return true; if (visited.has(id)) return false; visiting.add(id); const cycle = (graph.get(id) || []).some(visit); visiting.delete(id); visited.add(id); return cycle; }
|
|
59
73
|
graph.forEach((_, id) => { if (visit(id)) errors.push({ code: 'CYCLIC_DEPENDENCY', path: 'data.values', message: 'Gantt dependencies contain a cycle.', suggestion: 'Dependencies must form a directed acyclic graph.' }); });
|
|
@@ -134,6 +148,7 @@ function validateEncodingContract(input, spec, errors) {
|
|
|
134
148
|
|
|
135
149
|
export function normalizeSpec(input = {}) {
|
|
136
150
|
const spec = merge(defaults, input);
|
|
151
|
+
spec.title = normalizeTitle(spec.title);
|
|
137
152
|
if (input.branding === undefined && input.theme && typeof input.theme === 'object' && input.theme.branding !== undefined) spec.branding = clone(input.theme.branding);
|
|
138
153
|
if (spec.branding === false) spec.branding = { enabled: false };
|
|
139
154
|
else if (spec.branding === true) spec.branding = { enabled: true };
|
|
@@ -164,6 +179,15 @@ export function normalizeSpec(input = {}) {
|
|
|
164
179
|
export function validateSpec(input = {}) {
|
|
165
180
|
const spec = normalizeSpec(input);
|
|
166
181
|
const errors = [], warnings = [], normalizations = [];
|
|
182
|
+
if (typeof input.title === 'string') {
|
|
183
|
+
normalizations.push({ path: 'title', from: 'string', to: 'title.text' });
|
|
184
|
+
warnings.push({ code: 'NORMALIZED_TITLE', path: 'title', message: 'String titles are normalized to title.text.', suggestion: 'Prefer title: { text: "..." } for an explicit title contract.' });
|
|
185
|
+
} else if (input.title && typeof input.title === 'object' && !Array.isArray(input.title) && input.title.text === undefined && typeof input.title.label === 'string') {
|
|
186
|
+
normalizations.push({ path: 'title.label', from: 'title.label', to: 'title.text' });
|
|
187
|
+
warnings.push({ code: 'NORMALIZED_TITLE', path: 'title.label', message: 'title.label is normalized to title.text.', suggestion: 'Use title: { text: "..." }.' });
|
|
188
|
+
} else if (input.title !== undefined && (!input.title || typeof input.title !== 'object' || Array.isArray(input.title) || (input.title.text === undefined && input.title.subtitle === undefined))) {
|
|
189
|
+
warnings.push({ code: 'INVALID_TITLE', path: 'title', message: 'Title must be a string or an object with text and/or subtitle.', suggestion: 'Use title: { text: "...", subtitle: "..." }.' });
|
|
190
|
+
}
|
|
167
191
|
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().' });
|
|
168
192
|
if (!['canvas', 'svg', 'auto'].includes(spec.renderer)) errors.push({ code: 'INVALID_RENDERER', path: 'renderer', message: `Unsupported renderer: ${spec.renderer}`, suggestion: 'Use canvas, svg, or auto.' });
|
|
169
193
|
if (typeof spec.theme === 'string' && !themeModes.includes(spec.theme) && !themePresets.includes(spec.theme)) errors.push({ code: 'INVALID_THEME', path: 'theme', message: `Unsupported theme: ${spec.theme}`, suggestion: 'Use auto, light, dark, contrast, or a named style preset.' });
|
|
@@ -221,6 +245,7 @@ export function validateSpec(input = {}) {
|
|
|
221
245
|
const diagramInput = { type: spec.type, ...(Array.isArray(spec.nodes) ? { nodes: spec.nodes } : {}), ...(Array.isArray(spec.edges) ? { edges: spec.edges } : {}), ...(Array.isArray(spec.lanes) ? { lanes: spec.lanes } : {}), ...(Array.isArray(spec.groups) ? { groups: spec.groups } : {}), ...(Array.isArray(spec.layers) ? { layers: spec.layers } : {}), ...(Array.isArray(spec.boundaries) ? { boundaries: spec.boundaries } : {}), ...(spec.diagram ? { diagram: spec.diagram } : {}) };
|
|
222
246
|
const diagramValidation = validateDiagram(diagramInput);
|
|
223
247
|
diagramValidation.errors.forEach(error => errors.push({ ...error, path: error.path || 'diagram' }));
|
|
248
|
+
diagramValidation.warnings?.forEach(warning => warnings.push({ ...warning, path: warning.path || 'diagram' }));
|
|
224
249
|
}
|
|
225
250
|
if (['gantt', 'timeline', 'milestone', 'burndown'].includes(spec.type)) {
|
|
226
251
|
const rows = spec.data.values, dateKey = spec.type === 'gantt' ? 'start/end' : 'date';
|
|
@@ -236,12 +261,16 @@ export function validateSpec(input = {}) {
|
|
|
236
261
|
}
|
|
237
262
|
}
|
|
238
263
|
if (spec.type === 'gantt') errors.push(...dependencyErrors(spec.data.values));
|
|
264
|
+
if (spec.type === 'gantt') spec.data.values.forEach((row, index) => {
|
|
265
|
+
if (row && row.dependsOn !== undefined && row.dependencies === undefined) warnings.push({ code: 'UNSUPPORTED_DEPENDENCY_FIELD', path: `data.values[${index}].dependsOn`, message: 'dependsOn is not a recognized Gantt dependency field and will be ignored.', suggestion: 'Rename dependsOn to dependencies.' });
|
|
266
|
+
});
|
|
267
|
+
if (spec.type === 'scatter' && (Array.isArray(spec.encoding?.x) || Array.isArray(spec.encoding?.y)) && (Array.isArray(spec.encoding?.x) && spec.encoding.x.length > 1 || Array.isArray(spec.encoding?.y) && spec.encoding.y.length > 1)) errors.push({ code: 'UNSUPPORTED_SCATTER_SERIES', path: 'encoding', message: 'Scatter supports one x/y measure pair; multi-series encoding is not supported.', suggestion: 'Use one x and one y field, or choose line, area, bar, or column for series.' });
|
|
239
268
|
const rows = Array.isArray(spec.data?.values) ? spec.data.values : [];
|
|
240
269
|
if (spec.type === 'pie' && rows.length > 8) warnings.push({ code: 'HIGH_CARDINALITY_PIE', path: 'data.values', message: `Pie contains ${rows.length} categories.`, expected: '8 or fewer categories', suggestion: 'Use bar/column or group smaller categories.' });
|
|
241
270
|
if (spec.type === 'pie') {
|
|
242
271
|
const valueField = spec.encoding.value?.field || 'value', values = rows.map(row => Number(row?.[valueField])), negativeCount = values.filter(value => Number.isFinite(value) && value < 0).length, positiveTotal = values.reduce((sum, value) => sum + (Number.isFinite(value) && value > 0 ? value : 0), 0);
|
|
243
272
|
if (negativeCount) warnings.push({ code: 'NEGATIVE_VALUE_DROPPED', path: 'encoding.value', count: negativeCount, message: `Pie ignores ${negativeCount} negative value${negativeCount === 1 ? '' : 's'} when calculating shares.`, suggestion: 'Use non-negative part-to-whole values or choose a Cartesian chart for signed measures.' });
|
|
244
|
-
if (!(positiveTotal > 0))
|
|
273
|
+
if (!(positiveTotal > 0)) errors.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.' });
|
|
245
274
|
}
|
|
246
275
|
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.' });
|
|
247
276
|
const profile = chartProfiles[spec.type], inputOptions = input && typeof input === 'object' ? input : {};
|
package/types/index.d.ts
CHANGED
|
@@ -32,8 +32,9 @@ export interface ChartHealth { version: '1.0'; status: 'ready' | 'degraded' | 'e
|
|
|
32
32
|
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 }; }
|
|
33
33
|
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; }
|
|
34
34
|
export interface ChartEditing { enabled?: boolean; mode?: 'command' | string; requireConfirmation?: boolean; allowDelete?: boolean; allowStructuralChanges?: boolean; }
|
|
35
|
+
export interface ChartTitle { text?: string; subtitle?: string; label?: string; }
|
|
35
36
|
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; }
|
|
36
|
-
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?:
|
|
37
|
+
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?: string | ChartTitle; 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; }
|
|
37
38
|
export interface BinTransform { type: 'bin'; field: string; output?: string; thresholds?: number; step?: number; extent?: [number, number]; }
|
|
38
39
|
export interface RadarIndicator { name: string; field: string; min?: number; max?: number; }
|
|
39
40
|
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; }
|