@taylorwong/ichartjs 2.0.17 → 2.0.19
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 +12 -0
- package/README.md +2 -2
- package/docs/agent/charting-scenario.md +2 -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-14.md +58 -0
- package/docs/agent/development/iteration-15.md +48 -0
- package/docs/agent/development/roadmap.md +4 -2
- package/docs/agent/diagram-scenario.md +31 -0
- package/docs/agent/frontend-integration.md +1 -1
- package/docs/agent/quickstart.md +5 -0
- package/docs/agent/usage-scenarios.md +1 -1
- package/docs/agent/zh-CN/charting-scenario.md +2 -0
- package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
- package/docs/agent/zh-CN/diagram-scenario.md +31 -1
- package/docs/agent/zh-CN/frontend-integration.md +1 -1
- package/docs/agent/zh-CN/quickstart.md +3 -1
- package/docs/agent/zh-CN/usage-scenarios.md +1 -1
- package/docs/manifests/capabilities.json +9 -3
- package/docs/manifests/schemas.json +1 -0
- package/package.json +1 -1
- package/skills/ichartjs/SKILL.md +5 -4
- package/src/capabilities.mjs +2 -2
- package/src/charts.mjs +17 -5
- package/src/contract-registry.mjs +4 -2
- package/src/diagram.mjs +16 -0
- package/src/index.mjs +3 -3
- package/src/layout.mjs +68 -0
- package/src/project.mjs +120 -11
- package/src/schema.mjs +1 -1
- package/types/index.d.ts +61 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.19 - 2026-09-29
|
|
4
|
+
|
|
5
|
+
- Added Iteration 15 adaptive text fitting for Flow, Architecture, Mindmap, and Swimlane nodes, with inline-first edge labels, diagram-only contrast plates, unified diagnostics, and shared SVG/Canvas behavior.
|
|
6
|
+
- Improved diagram readability with vertically centered node labels, bounded compact labels, centered Swimlane nodes, safer edge-label placement, and enlarged-preview re-rendering.
|
|
7
|
+
- Added sector-aware Pie/Donut labels and radius-aware Gauge metrics, including bounded font reduction, explicit `POLAR_LABEL_OVERFLOW` diagnostics, and machine-readable polar layout state.
|
|
8
|
+
|
|
9
|
+
## 2.0.18 - 2026-09-28
|
|
10
|
+
|
|
11
|
+
- Added lightweight Flow semantics for start/end, process, decision, input/output, and connector nodes, including explicit decision branches and loop edges with shared SVG/Canvas geometry.
|
|
12
|
+
- Exposed Flow semantic capabilities and validation diagnostics through runtime metadata, TypeScript declarations, Agent Skill guidance, and generated manifests.
|
|
13
|
+
- Updated the full Gallery Flow example to demonstrate all supported semantic node kinds, Yes/No branches, a retry loop, and connector usage.
|
|
14
|
+
|
|
3
15
|
## 2.0.17 - 2026-09-25
|
|
4
16
|
|
|
5
17
|
- Added explicit `FUNNEL_LABEL_TRUNCATED` diagnostics when narrow stages cannot display their full labels.
|
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.19`.
|
|
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.19/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
|
|
|
@@ -28,6 +28,8 @@ Agent usage and development guide for generic data analysis and metric visualiza
|
|
|
28
28
|
|
|
29
29
|
- Use `stack: "stacked"` or `stack: "percent"` with Bar, Column, or Area rather than a separate stacked type.
|
|
30
30
|
- Use `type: "pie"` with `innerRadius` for Donut charts.
|
|
31
|
+
- Pie and Donut labels are positioned on each sector centerline and scale down before allowing overflow on very small sectors. Check `getState().layout.labels.polar` and the `POLAR_LABEL_OVERFLOW` warning when labels are required to be fully readable.
|
|
32
|
+
- Gauge keeps its primary value inside the gauge body and adapts its metric font to the available radius; it does not create per-segment labels.
|
|
31
33
|
- Use per-series `mark: "column"` or `mark: "line"` and optional `axis: "right"` for Combo charts.
|
|
32
34
|
- Use `transform: { type: "bin", field, thresholds | step, extent }` for Histogram workflows.
|
|
33
35
|
- Heatmap treats missing values separately from numeric zero through `colorScale.missing`.
|
|
@@ -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.19/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.19` includes Iteration 13 contract hardening, Iteration 14 lightweight Flow semantics, and Iteration 15 text-readability 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, Timeline/Milestone layout hardening, renderer-parity Flow semantic shapes, adaptive diagram labels, diagram-only edge-label plates, and polar label layout diagnostics for Pie, Donut, and Gauge.
|
|
44
44
|
|
|
45
45
|
## Deferred npm Publication
|
|
46
46
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Iteration 14 — Lightweight Flow Semantics
|
|
2
|
+
|
|
3
|
+
Iteration 14 enhances the existing `flow` chart instead of adding a new chart type. The goal is a small, readable process notation that an Agent can generate from a compact JSON Spec without introducing BPMN-level complexity.
|
|
4
|
+
|
|
5
|
+
## Scope
|
|
6
|
+
|
|
7
|
+
### 14A — Semantic nodes
|
|
8
|
+
|
|
9
|
+
Flow nodes support the following `kind` values:
|
|
10
|
+
|
|
11
|
+
| Kind | Shape | Meaning |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| `start` | ellipse | Process start |
|
|
14
|
+
| `end` | ellipse | Process end |
|
|
15
|
+
| `process` | rectangle | Operation or step |
|
|
16
|
+
| `decision` | diamond | Condition with branches |
|
|
17
|
+
| `io` | parallelogram | Input or output |
|
|
18
|
+
| `connector` | circle | Explicit hand-off point for a disconnected flow |
|
|
19
|
+
|
|
20
|
+
Nodes without `kind` use `process` as the minimal default. Non-Flow diagrams continue to use their existing node visual language.
|
|
21
|
+
|
|
22
|
+
### 14B — Process structures
|
|
23
|
+
|
|
24
|
+
- Sequential execution uses ordinary `from` → `to` edges.
|
|
25
|
+
- Decision branches use `edge.label`, normally `yes` / `no` or localized equivalents.
|
|
26
|
+
- Loops are ordinary edges that point to an earlier node; cycles are allowed for Flow.
|
|
27
|
+
- Connectors never use implicit matching. Every connection is explicit in `edges`.
|
|
28
|
+
|
|
29
|
+
The capability contract exposes these rules through `getCapabilities().diagram.flowNodeKinds`, `flowBranchEdges`, `flowLoops`, and `flowConnectors`.
|
|
30
|
+
|
|
31
|
+
### 14C — Layout and rendering
|
|
32
|
+
|
|
33
|
+
- Existing layered, manual, and orthogonal layout/routing remain the source of truth.
|
|
34
|
+
- Semantic shapes share the existing Scene Graph, hit testing, selection, edge routing, export, and accessibility surfaces.
|
|
35
|
+
- SVG and Canvas use the same node geometry and edge path data.
|
|
36
|
+
- Process-only Flow remains concise when `kind` is omitted; this is a convenience default, not a migration compatibility promise. Non-process semantics should use an explicit `kind`.
|
|
37
|
+
|
|
38
|
+
### 14D — Validation and Agent guidance
|
|
39
|
+
|
|
40
|
+
- Unknown `nodes[].kind` values are validation errors.
|
|
41
|
+
- A semantic `decision` with fewer than two outgoing edges produces `FLOW_DECISION_BRANCHES`.
|
|
42
|
+
- Semantic Flows without `start` or `end` produce actionable warnings.
|
|
43
|
+
- Unlabeled connectors produce `FLOW_CONNECTOR_LABEL` as a readability warning.
|
|
44
|
+
- Flow schemas, TypeScript declarations, recipes, bilingual guides, manifests, and focused tests stay synchronized.
|
|
45
|
+
|
|
46
|
+
## Acceptance
|
|
47
|
+
|
|
48
|
+
- All six node kinds render in both SVG and Canvas.
|
|
49
|
+
- Sequential, branch, and loop structures validate and render.
|
|
50
|
+
- Connector paths remain explicit and inspectable through `explain()` / JSON export.
|
|
51
|
+
- Old rectangle-only Flow examples remain renderable through the `process` default.
|
|
52
|
+
- `npm run contracts:check`, `npm run types:check`, `npm run docs:check`, `npm test`, and browser acceptance pass.
|
|
53
|
+
|
|
54
|
+
## Non-goals
|
|
55
|
+
|
|
56
|
+
- No `note` node in this iteration.
|
|
57
|
+
- No BPMN events, gateways, subprocesses, or implicit connector name resolution.
|
|
58
|
+
- No new chart type, CLI, MCP, HTTP, or Python runtime.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Iteration 15 — Text Readability Hardening
|
|
2
|
+
|
|
3
|
+
Iteration 15 improves text readability without adding a new chart type or a new runtime dependency. The policy is semantic: node labels stay inside nodes, while edge labels remain inline when there is enough space and move only when they collide.
|
|
4
|
+
|
|
5
|
+
## 15A — Adaptive node labels
|
|
6
|
+
|
|
7
|
+
Flow, Architecture, Mindmap, and Swimlane node labels use one shared layout policy:
|
|
8
|
+
|
|
9
|
+
1. Keep the configured theme size when a single line fits.
|
|
10
|
+
2. Wrap to at most two lines when the node can support it.
|
|
11
|
+
3. Reduce the font size to a bounded minimum of `10px`.
|
|
12
|
+
4. Truncate only the final visible line when the label still cannot fit.
|
|
13
|
+
5. Suppress labels in physically tiny nodes such as compact connectors.
|
|
14
|
+
|
|
15
|
+
The raw label remains available through node data, tooltips, accessibility output, and the Agent state. Nodes use shape-specific inner padding so labels do not touch decision or I/O edges.
|
|
16
|
+
|
|
17
|
+
## 15B — Semantic edge-label placement
|
|
18
|
+
|
|
19
|
+
Edge labels use this priority:
|
|
20
|
+
|
|
21
|
+
1. Place the label inline near the edge midpoint.
|
|
22
|
+
2. Add a small background plate when the edge passes behind the label.
|
|
23
|
+
3. Move the label to one side when it collides with a node or another label.
|
|
24
|
+
4. Suppress only non-core labels when no readable placement exists.
|
|
25
|
+
|
|
26
|
+
Node labels never move onto edges. Edge routing remains responsible for stopping at node boundaries. The same scene graph is used by SVG and Canvas.
|
|
27
|
+
|
|
28
|
+
Radar indicator labels use collision-aware placement without background plates. Background plates are reserved for diagram edge labels in Flow, Architecture, Mindmap, and Swimlane; ordinary chart labels retain direct, contrast-aware rendering.
|
|
29
|
+
|
|
30
|
+
## 15C — Unified label diagnostics
|
|
31
|
+
|
|
32
|
+
`Chart#getState().layout.labels` exposes family-specific label results, including visible, wrapped, scaled, truncated, suppressed, collision, edge-inline, edge-offset, and backgrounded counts where applicable.
|
|
33
|
+
|
|
34
|
+
Warnings are emitted for information loss or suppression, including `LABEL_TRUNCATED` and `LABELS_SUPPRESSED`. Agent workflows should inspect `getState()` or `explain()` before assuming that a visible chart contains every original label.
|
|
35
|
+
|
|
36
|
+
## 15D — Documentation and acceptance
|
|
37
|
+
|
|
38
|
+
- Shared measurement and fitting helpers live in `src/layout.mjs`.
|
|
39
|
+
- Diagram behavior is implemented in `src/project.mjs`.
|
|
40
|
+
- Generic mark and Radar labels use plate-free collision-aware placement in `src/charts.mjs`.
|
|
41
|
+
- SVG and Canvas use the same scene nodes and label geometry.
|
|
42
|
+
- Acceptance covers CJK and Latin labels, compact nodes, inline edge labels, plate-free Pie/line/Radar labels, and Architecture/Mindmap/Swimlane parity.
|
|
43
|
+
|
|
44
|
+
Preview pages:
|
|
45
|
+
|
|
46
|
+
- `playground/project-gallery.html?case=flow`
|
|
47
|
+
- `playground/project-gallery.html?case=architecture`
|
|
48
|
+
- `playground/project-gallery.html?case=radar`
|
|
@@ -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.19` includes the completed Iteration 12 structured-diagram work, Iteration 13 contract hardening, Iteration 14 lightweight Flow semantics, Iteration 15 text-readability hardening, sector-aware Pie/Donut labels, radius-aware Gauge metrics, and the preceding layout and diagnostic improvements. Geographic charts and 3D rendering remain out of scope until explicitly reintroduced.
|
|
4
4
|
|
|
5
5
|
## Current Status
|
|
6
6
|
|
|
@@ -15,7 +15,9 @@
|
|
|
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–13F is complete and included in `v2.0.
|
|
18
|
+
- Iteration 13A–13F is complete and included in `v2.0.18`: 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, Timeline/Milestone layout hardening, and runtime diagnostics for constrained Funnel labels and Swimlane label aliases. No chart type was added.
|
|
19
|
+
- Iteration 14A–14D is complete and included in `v2.0.18`: lightweight Flow semantics for start/end, process, decision, input/output, and connector nodes, explicit decision branches and loops, shared SVG/Canvas geometry, capability discovery, validation, and Gallery coverage. No new chart type was added.
|
|
20
|
+
- Iteration 15A–15D is complete and included in `v2.0.19`: adaptive diagram node labels, semantic inline-first edge labels, diagram edge-label background plates, plate-free generic chart labels, shared SVG/Canvas fitting, unified label diagnostics, Agent/diagram documentation, and polar label layout hardening for Pie, Donut, and Gauge.
|
|
19
21
|
- 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
22
|
|
|
21
23
|
## Iteration 4 — Agent Data Contract and Business Editing
|
|
@@ -43,11 +43,42 @@ For a mindmap, prefer the compact parent contract:
|
|
|
43
43
|
}
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
### Lightweight Flow Semantics
|
|
47
|
+
|
|
48
|
+
`flow` supports a small, unambiguous set of semantic node kinds. Nodes without `kind` use `process` as a convenience default; non-process semantics should be explicit.
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
{
|
|
52
|
+
type: 'flow',
|
|
53
|
+
nodes: [
|
|
54
|
+
{ id: 'start', kind: 'start', label: 'Start' },
|
|
55
|
+
{ id: 'load', kind: 'process', label: 'Load data' },
|
|
56
|
+
{ id: 'valid', kind: 'decision', label: 'Valid?' },
|
|
57
|
+
{ id: 'output', kind: 'io', label: 'Output result' },
|
|
58
|
+
{ id: 'loop', kind: 'connector', label: 'retry' },
|
|
59
|
+
{ id: 'end', kind: 'end', label: 'End' }
|
|
60
|
+
],
|
|
61
|
+
edges: [
|
|
62
|
+
{ id: 'start-load', from: 'start', to: 'load' },
|
|
63
|
+
{ id: 'load-valid', from: 'load', to: 'valid' },
|
|
64
|
+
{ id: 'valid-yes', from: 'valid', to: 'output', label: 'yes' },
|
|
65
|
+
{ id: 'valid-no', from: 'valid', to: 'loop', label: 'no' },
|
|
66
|
+
{ id: 'loop-load', from: 'loop', to: 'load' },
|
|
67
|
+
{ id: 'output-end', from: 'output', to: 'end' }
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Supported kinds are `start`, `end`, `process`, `decision`, `io`, and `connector`. A decision should have at least two outgoing labeled edges. Loops are ordinary explicit `from`/`to` edges and are allowed. Connectors are circular hand-off points; they do not use implicit matching, so every connection remains visible in `edges`.
|
|
73
|
+
|
|
74
|
+
Node labels are centered on the node and use adaptive fitting: they wrap to at most two lines, reduce to a bounded minimum font size, and truncate only as a last resort. Compact nodes such as small connectors suppress their internal label rather than rendering unreadable text; the full label remains in the node data and accessibility surface. Edge labels stay inline when space permits, use a high-opacity background plate when the line would reduce contrast, and move aside only when they collide with nodes or other labels. `getState().layout.labels` and `explain().warnings` expose any wrapping, scaling, truncation, or suppression.
|
|
75
|
+
|
|
46
76
|
## Current Capabilities
|
|
47
77
|
|
|
48
78
|
Supported:
|
|
49
79
|
|
|
50
80
|
- Nodes, edges, lanes, groups, and ports.
|
|
81
|
+
- Flow semantic shapes: start/end, process, decision, input/output, and connector.
|
|
51
82
|
- `manual`, `layered`, `tree`, and `radial` layouts.
|
|
52
83
|
- Architecture layers and boundaries, plus mindmap parent-child derivation.
|
|
53
84
|
- `straight`, `orthogonal`, and true cubic-Bezier `curved` routing, with adjustable `curveTension` and obstacle-aware orthogonal fallback.
|
|
@@ -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.19`.
|
|
14
14
|
|
|
15
15
|
Use the package through a bundler or another environment that resolves npm ESM imports:
|
|
16
16
|
|
package/docs/agent/quickstart.md
CHANGED
|
@@ -103,6 +103,8 @@ const spec = {
|
|
|
103
103
|
|
|
104
104
|
Use the selected capability and recipes for Pie, Gauge, Heatmap, Radar, project views, and diagrams because their required encodings differ.
|
|
105
105
|
|
|
106
|
+
Use `data: { values: rows }` as the canonical public shape. The runtime also accepts a raw row array for compatibility, and the minimal recipe catalog uses that shorter form; do not mix diagram collections into `data.values`. Diagram inputs such as `nodes`, `edges`, `lanes`, `layers`, and `boundaries` stay at the Spec top level.
|
|
107
|
+
|
|
106
108
|
#### Put options at the contract level
|
|
107
109
|
|
|
108
110
|
Keep `encoding` for field roles and series semantics. Put presentation and axis options at the Spec level:
|
|
@@ -147,6 +149,8 @@ if (!validation.valid) {
|
|
|
147
149
|
|
|
148
150
|
Do not silently discard diagnostics. Preserve stable `code`, `path`, `message`, `expected`, and `suggestion` fields in Agent output and automated repair loops.
|
|
149
151
|
|
|
152
|
+
Diagnostics have two phases: `validateSpec()` reports preflight contract problems, while layout-dependent warnings can appear only after `createChart()` in `chart.getState().warnings` and `chart.explain().warnings`. Always inspect both before returning a chart, especially for narrow Funnel labels.
|
|
153
|
+
|
|
150
154
|
### 6. Render
|
|
151
155
|
|
|
152
156
|
Browser rendering:
|
|
@@ -165,6 +169,7 @@ Headless environment capabilities:
|
|
|
165
169
|
- JSON and SVG string export work with zero dependencies.
|
|
166
170
|
- PNG/JPEG raster export requires the optional `canvas` npm package; otherwise a structured `HEADLESS_EXPORT_UNSUPPORTED` error is returned.
|
|
167
171
|
- The on-screen renderer is decoupled from the export backend; any mounted renderer can export any supported format.
|
|
172
|
+
- Prefer `svg` for accessibility, DOM inspection, and diagram editing; prefer `canvas` for larger mark counts when lower DOM overhead matters.
|
|
168
173
|
|
|
169
174
|
### 7. Explain, Export, and Self-Check
|
|
170
175
|
|
|
@@ -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.19/skills/ichartjs \
|
|
125
125
|
--agent codex --global --yes
|
|
126
126
|
```
|
|
127
127
|
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
|
|
29
29
|
- Bar、Column、Area 使用 `stack: "stacked"` 或 `stack: "percent"`,不新增堆叠类型。
|
|
30
30
|
- Donut 使用 `type: "pie"` 配合 `innerRadius`。
|
|
31
|
+
- Pie 和 Donut 标签会沿扇区中线布局,并在扇区过小时先缩小字号,再允许自然溢出。需要检查完整可读性时,读取 `getState().layout.labels.polar` 和 `POLAR_LABEL_OVERFLOW` 告警。
|
|
32
|
+
- Gauge 的主数值会保持在仪表主体内,并根据半径自适应字号;Gauge 不生成每个分段的标签。
|
|
31
33
|
- Combo 使用每个系列的 `mark: "column"` 或 `mark: "line"`,并可设置 `axis: "right"`。
|
|
32
34
|
- Histogram 使用 `transform: { type: "bin", field, thresholds | step, extent }`。
|
|
33
35
|
- Heatmap 通过 `colorScale.missing` 区分缺失值和数值零。
|
|
@@ -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.19/skills/ichartjs`。WorkBuddy 可通过自身 Skill 界面导入该带 Tag 的目录;只有当前 CLI 明确声明对应适配器时才使用宿主专用 `--agent` 参数。
|
|
26
26
|
|
|
27
27
|
使用 `npx skills add wanghetommy/ichartjs --list` 验证发现结果,其中应包含 `ichartjs`。
|
|
28
28
|
|
|
@@ -42,9 +42,39 @@ Architecture 的 `layers` 是从上到下排列的水平分层。同层自动布
|
|
|
42
42
|
}
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
## 轻量级流程语义
|
|
46
|
+
|
|
47
|
+
`flow` 支持一组小而明确的节点语义。没有设置 `kind` 的节点默认使用 `process`,这是便捷默认值;其他语义应显式设置 `kind`。
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
{
|
|
51
|
+
type: 'flow',
|
|
52
|
+
nodes: [
|
|
53
|
+
{ id: 'start', kind: 'start', label: '开始' },
|
|
54
|
+
{ id: 'load', kind: 'process', label: '读取数据' },
|
|
55
|
+
{ id: 'valid', kind: 'decision', label: '是否有效?' },
|
|
56
|
+
{ id: 'output', kind: 'io', label: '输出结果' },
|
|
57
|
+
{ id: 'loop', kind: 'connector', label: '重试' },
|
|
58
|
+
{ id: 'end', kind: 'end', label: '结束' }
|
|
59
|
+
],
|
|
60
|
+
edges: [
|
|
61
|
+
{ id: 'start-load', from: 'start', to: 'load' },
|
|
62
|
+
{ id: 'load-valid', from: 'load', to: 'valid' },
|
|
63
|
+
{ id: 'valid-yes', from: 'valid', to: 'output', label: '是' },
|
|
64
|
+
{ id: 'valid-no', from: 'valid', to: 'loop', label: '否' },
|
|
65
|
+
{ id: 'loop-load', from: 'loop', to: 'load' },
|
|
66
|
+
{ id: 'output-end', from: 'output', to: 'end' }
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
支持的 `kind` 为 `start`、`end`、`process`、`decision`、`io` 和 `connector`。判断节点建议至少有两条带标签的出边;循环就是普通的显式 `from` / `to` 回边。Connector 是圆形的断开连接点,不使用隐式同名匹配,所有连接都必须明确写在 `edges` 中。
|
|
72
|
+
|
|
73
|
+
节点文字默认在节点内居中,并按自适应规则处理:优先保持字号,必要时最多换成两行,再缩小到有限的最小字号,最后才截断。尺寸过小的节点(例如小型 Connector)会隐藏内部文字,避免显示不全;完整文字仍保留在节点数据和无障碍语义中。连线文字在空间足够时优先贴近连线显示;线条影响对比度时使用高不透明度背景底板,只有与节点或其他文字冲突时才移到连线旁边。`getState().layout.labels` 和 `explain().warnings` 会暴露换行、缩放、截断和隐藏结果。
|
|
74
|
+
|
|
45
75
|
## 当前能力
|
|
46
76
|
|
|
47
|
-
已支持节点、边、泳道、Group、Port
|
|
77
|
+
已支持节点、边、泳道、Group、Port、Flow 语义图形、四种布局、三种路由、Mindmap 三次贝塞尔曲线、节点拖动、多选、对齐、网格吸附、键盘移动、Copy/Paste、Group 折叠展开、Port 键盘连线、边选择、折点/正交线段手柄、持久化 `waypoints`、边删除和 Undo/Redo。
|
|
48
78
|
|
|
49
79
|
导航和编辑能力默认关闭。普通图表保持静态,缩放、平移、框选、节点拖动、线段拖动、Port 连线和结构编辑必须由宿主显式开启:
|
|
50
80
|
|
|
@@ -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.19`。
|
|
10
10
|
|
|
11
11
|
```js
|
|
12
12
|
import { createChart } from '@taylorwong/ichartjs';
|
|
@@ -34,7 +34,7 @@ Agent 与开发者使用同一个 ESM 入口。编码 Agent 的完整方式见 [
|
|
|
34
34
|
4. 当 `requiredFields` 非空时停止渲染,向用户请求数据或选择有依据的备选方案。
|
|
35
35
|
5. 构建 JSON 可序列化的 Spec,并调用 `validateSpec()`。Spec 可通过 `branding: false` 显式关闭品牌署名;默认保留署名以提升项目可见性。
|
|
36
36
|
6. 仅在 `validation.valid` 为 `true` 时调用 `createChart()`。
|
|
37
|
-
7. 用 `chart.explain()`、`chart.getState()`、JSON/SVG/PNG export
|
|
37
|
+
7. 用 `chart.explain()`、`chart.getState()`、JSON/SVG/PNG export 完成自检。JSON/SVG 可直接使用同步 `chart.export()`;浏览器 PNG 使用 `chart.toDataURL()` 或 `chart.downloadPNG()`;Node 无头 PNG/JPEG 使用 `chart.exportAsync()`,可选安装 `canvas`。最后调用 `chart.destroy()`。
|
|
38
38
|
|
|
39
39
|
### 意图必须使用注册词
|
|
40
40
|
|
|
@@ -70,6 +70,8 @@ const spec = structuredClone(catalog.examples.radar);
|
|
|
70
70
|
|
|
71
71
|
Agent 自检使用 `chart.getState().health` 和 `chart.explain().health`,其中包含 `ready`、`degraded`、`empty`、警告数、隐藏标签数、限制值数和已渲染标记数。`locale` 默认 `en-US`,需要中文输出时设置 `locale: "zh-CN"`,输入日期仍使用 ISO-8601。
|
|
72
72
|
|
|
73
|
+
推荐正式数据写法是 `data: { values: rows }`;Runtime 也兼容直接传入行数组,最小 Recipe 为保持紧凑使用了数组形式。Flow、Swimlane、Architecture、Mindmap 的 `nodes`、`edges`、`lanes`、`layers` 和 `boundaries` 必须放在 Spec 顶层。`validateSpec()` 负责渲染前的契约检查;窄 Funnel 标签等依赖布局的告警要在 `chart.getState().warnings` 或 `chart.explain().warnings` 中检查。需要 DOM 可访问性或 Diagram 编辑时优先 SVG,大量标记且希望降低 DOM 开销时选择 Canvas。
|
|
74
|
+
|
|
73
75
|
## 品牌署名(Branding)默认行为
|
|
74
76
|
|
|
75
77
|
- 默认 `branding: true`:在画面与所有导出产物(PNG/SVG/JSON)右下角同步出现 `Powered by iChart.js` 低对比度署名。
|
|
@@ -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.19/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.19",
|
|
286
|
+
"runtimeVersion": "2.0.19",
|
|
287
287
|
"chartTypes": [
|
|
288
288
|
"line",
|
|
289
289
|
"area",
|
|
@@ -1080,7 +1080,9 @@
|
|
|
1080
1080
|
"optional": [
|
|
1081
1081
|
"edges",
|
|
1082
1082
|
"groups",
|
|
1083
|
-
"ports"
|
|
1083
|
+
"ports",
|
|
1084
|
+
"semantic-node-kinds",
|
|
1085
|
+
"connectors"
|
|
1084
1086
|
],
|
|
1085
1087
|
"interactions": [
|
|
1086
1088
|
"hover",
|
|
@@ -1112,6 +1114,10 @@
|
|
|
1112
1114
|
"layout": "supported",
|
|
1113
1115
|
"groups": "supported",
|
|
1114
1116
|
"ports": "supported",
|
|
1117
|
+
"semantic-shapes": "supported",
|
|
1118
|
+
"decision-branches": "supported",
|
|
1119
|
+
"connectors": "supported",
|
|
1120
|
+
"loops": "supported",
|
|
1115
1121
|
"history": "supported",
|
|
1116
1122
|
"axes": "not-applicable",
|
|
1117
1123
|
"grid": "not-applicable",
|
package/package.json
CHANGED
package/skills/ichartjs/SKILL.md
CHANGED
|
@@ -21,9 +21,9 @@ 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.19/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
|
-
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
|
|
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 npm:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
29
|
npm install @taylorwong/ichartjs@^2
|
|
@@ -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`. Funnel stage text comes from `encoding.category` (default `name`) and `labels.enabled` additionally renders values; narrow stages may report `FUNNEL_LABEL_TRUNCATED`, so increase width or shorten the stage name. 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.
|
|
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 `labels.enabled` additionally renders values; narrow stages may report `FUNNEL_LABEL_TRUNCATED`, so increase width or shorten the stage name. For Flow, use top-level `nodes` and `edges`, `nodes[].kind` from `getCapabilities().diagram.flowNodeKinds`, and edge `label` for decision branches. `connector` uses explicit `from`/`to` edges and loops are allowed. 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.
|
|
45
|
+
10. Call `validateSpec()` before rendering and repair its preflight errors, warnings, and normalizations. After `createChart()`, inspect `chart.getState()` or `chart.explain()` for render-time diagnostics such as `FUNNEL_LABEL_TRUNCATED`, `VALUE_CLAMPED`, `LABELS_SUPPRESSED`, `LABEL_TRUNCATED`, `NEGATIVE_VALUE_DROPPED`, and `ZERO_TOTAL`; also inspect `getState().layout.labels` for wrapped, scaled, inline-edge, offset-edge, and diagram edge-label background counts. Ordinary chart labels do not use background plates. Do not assume every layout warning is available during preflight.
|
|
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`, `FUNNEL_LABEL_TRUNCATED`, 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.
|
|
@@ -64,6 +64,7 @@ Route by requested output:
|
|
|
64
64
|
- For standard data analysis, read `references/chart-selection.md` and use foundational recipes.
|
|
65
65
|
- For Gantt, Timeline, Milestone, Burndown, capacity, release, risk, or aging, use project capabilities and `agent-recipes/project-management.json`.
|
|
66
66
|
- For Flow or Swimlane, preserve node, edge, lane, group, and port IDs; use diagram recipes and validated edit commands.
|
|
67
|
+
- For diagram readability, keep node labels inside their shapes; edge labels stay inline when space permits and move aside only after a collision. Use `getState().layout.labels.diagram` to detect wrapping, scaling, truncation, suppression, and edge-label offsets instead of shortening source labels in the Agent.
|
|
67
68
|
- For business edits, preview first, preserve the preview ID and revision, require confirmation when declared, then commit or reject atomically.
|
|
68
69
|
- For browser deliverables, start `npm run playground` and return the exact maintained Playground URL.
|
|
69
70
|
- Release workflow (npm publish + develop→master merge) is **AUTHOR ONLY**. Read the [release SOP](https://github.com/wanghetommy/ichartjs/blob/master/docs/agent/development/release-sop.md). Never initiate any release step unless the author explicitly instructs.
|
package/src/capabilities.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { inspectData } from './data.mjs';
|
|
5
5
|
import { planStyle, styleCapabilities } from './theme.mjs';
|
|
6
6
|
import { defaultPreferences, preferenceDensities, preferenceMotions, preferenceTriStates } from './preferences.mjs';
|
|
7
|
-
import { businessModels, chartProfiles, chartTypes, commandTypes, contractVersion, diagramEdgeModels, diagramOperations, exportTypes, interactionDefaults, renderers } from './contract-registry.mjs';
|
|
7
|
+
import { businessModels, chartProfiles, chartTypes, commandTypes, contractVersion, diagramEdgeModels, diagramOperations, exportTypes, flowNodeKinds, interactionDefaults, renderers } from './contract-registry.mjs';
|
|
8
8
|
|
|
9
9
|
export { chartProfiles, chartTypes };
|
|
10
10
|
|
|
@@ -199,7 +199,7 @@ export function getCapabilities() {
|
|
|
199
199
|
charts: JSON.parse(JSON.stringify(chartProfiles)),
|
|
200
200
|
intents,
|
|
201
201
|
locale: { default: 'en-US', recommended: ['en-US', 'zh-CN'], appliesTo: ['axis', 'labels', 'tooltip', 'export'], inputDates: 'ISO-8601 strings; natural-language date parsing is not supported.' },
|
|
202
|
-
chartModes: { stack: ['stacked', 'percent'], pie: ['standard', 'donut'], composition: ['multi-series', 'mixed-line-column', 'dual-axis'], transforms: ['bin'], diagrams: ['process', 'architecture', 'mindmap'], mindmapLayouts: ['tree', 'radial'], mindmapEdges: ['curved', 'straight', 'orthogonal'] },
|
|
202
|
+
chartModes: { stack: ['stacked', 'percent'], pie: ['standard', 'donut'], composition: ['multi-series', 'mixed-line-column', 'dual-axis'], transforms: ['bin'], diagrams: ['process', 'architecture', 'mindmap'], mindmapLayouts: ['tree', 'radial'], mindmapEdges: ['curved', 'straight', 'orthogonal'], flowNodeKinds: [...flowNodeKinds], flowBranchEdges: { label: 'edge.label', minimumOutgoing: 2 }, flowLoops: 'supported', flowConnectors: { kind: 'connector', linking: 'explicit from/to edges' } },
|
|
203
203
|
projectManagement: project,
|
|
204
204
|
projectIntelligence: {
|
|
205
205
|
intents: ['schedule', 'milestone', 'progress', 'variance', 'capacity', 'release', 'risk', 'aging', 'workflow', 'responsibility', 'architecture', 'mindmap'],
|
package/src/charts.mjs
CHANGED
|
@@ -8,10 +8,11 @@ import { applyTransforms } from './transforms.mjs';
|
|
|
8
8
|
import { buildProjectScene } from './project.mjs';
|
|
9
9
|
import { formatValue } from './format.mjs';
|
|
10
10
|
import { contrastRatio, resolveTheme } from './theme.mjs';
|
|
11
|
-
import { axisLabelLayout, boxesOverlap, estimateTextWidth, fontSize, titleLayout, truncateText } from './layout.mjs';
|
|
11
|
+
import { axisLabelLayout, boxesOverlap, estimateTextWidth, fontSize, polarLabelLayout, titleLayout, truncateText } from './layout.mjs';
|
|
12
12
|
|
|
13
13
|
const pick = (row, encoding, fallback) => row?.[encoding?.field || fallback];
|
|
14
14
|
const font = (spec, role, fallback = '12px system-ui') => spec.theme?.typography?.[role]?.font || fallback;
|
|
15
|
+
function fontAtSize(value, size) { const raw = String(value || '12px system-ui'); return /\d+(?:\.\d+)?px/i.test(raw) ? raw.replace(/\d+(?:\.\d+)?px/i, `${size}px`) : `${size}px ${raw}`; }
|
|
15
16
|
const niceMantissas = [1, 1.2, 1.5, 1.8, 2, 2.5, 3, 4, 5, 6, 7.5, 8, 10];
|
|
16
17
|
|
|
17
18
|
function nearestNiceMantissa(value) {
|
|
@@ -192,7 +193,7 @@ function layout(spec, data, legendEntries = [], layoutOptions = {}) {
|
|
|
192
193
|
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 };
|
|
193
194
|
}
|
|
194
195
|
|
|
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 })); }
|
|
196
|
+
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, zIndex: 4 })); }
|
|
196
197
|
|
|
197
198
|
function addAxes(scene, spec, state) {
|
|
198
199
|
const { plot, categories, min, max, x, y, yRight } = state;
|
|
@@ -248,6 +249,7 @@ function readableLabelColor(spec, background) {
|
|
|
248
249
|
|
|
249
250
|
function addMarkLabel(scene, spec, id, value, x, y, dataRef, bounds = null, anchor = null, formatOverride = null, labelBackground = null) {
|
|
250
251
|
if (!spec.labels?.enabled) return;
|
|
252
|
+
const labelState = scene._labelLayout || (scene._labelLayout = { visible: 0, wrapped: 0, scaled: 0, truncated: 0, suppressed: 0, collisions: 0, backgrounded: 0 });
|
|
251
253
|
const text = formatValue(value, formatOverride || spec.labels.format, spec.locale), size = fontSize(spec, 'label', 12), width = estimateTextWidth(text, size), height = size * 1.25;
|
|
252
254
|
const baseArea = bounds || scene._labelArea || { x: 0, y: 0, width: spec.width, height: spec.height };
|
|
253
255
|
const area = spec.type === 'column' && !anchor ? { ...baseArea, y: baseArea.y - height - 4, height: baseArea.height + height + 4 } : baseArea;
|
|
@@ -262,8 +264,9 @@ function addMarkLabel(scene, spec, id, value, x, y, dataRef, bounds = null, anch
|
|
|
262
264
|
const shifts = anchor ? [preferred, alternate, preferred - height - 3, alternate + height + 3] : [safeY, safeY - height - 3, safeY + height + 3, safeY - 2 * (height + 3), safeY + 2 * (height + 3)];
|
|
263
265
|
const occupied = scene._labelBoxes || (scene._labelBoxes = []);
|
|
264
266
|
const placement = shifts.map(candidateY => Math.max(area.y + height / 2, Math.min(area.y + area.height - height / 2, candidateY))).map(candidateY => ({ x: safeX, y: candidateY, box: { x: safeX - width / 2, y: candidateY - height / 2, width, height } })).find(candidate => !occupied.some(box => boxesOverlap(candidate.box, box, 2)) && (!anchorBox || !boxesOverlap(candidate.box, anchorBox, 0)));
|
|
265
|
-
if (!placement) return;
|
|
267
|
+
if (!placement) { labelState.suppressed += 1; labelState.collisions += 1; return; }
|
|
266
268
|
occupied.push(placement.box);
|
|
269
|
+
labelState.visible += 1;
|
|
267
270
|
addText(scene, `${id}-label`, text, placement.x, placement.y, { fill: labelBackground || markBackground ? readableLabelColor(spec, labelBackground || markBackground) : spec.labels.color || spec.theme.text, font: spec.labels.font || font(spec, 'label'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }, dataRef);
|
|
268
271
|
}
|
|
269
272
|
|
|
@@ -400,6 +403,7 @@ export function buildScene(spec) {
|
|
|
400
403
|
data.viewOffset = start;
|
|
401
404
|
}
|
|
402
405
|
const scene = new Scene(spec.width, spec.height);
|
|
406
|
+
scene._labelLayout = { visible: 0, wrapped: 0, scaled: 0, truncated: 0, suppressed: 0, collisions: 0, backgrounded: 0 };
|
|
403
407
|
const colors = spec.colors;
|
|
404
408
|
const series = Array.isArray(spec.encoding.y) ? spec.encoding.y : [{ ...spec.encoding.y, name: spec.encoding.y?.name || spec.encoding.y?.field }];
|
|
405
409
|
const entries = legendEntries(spec, data, series, colors);
|
|
@@ -425,9 +429,12 @@ export function buildScene(spec) {
|
|
|
425
429
|
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 }); });
|
|
426
430
|
} else if (spec.type === 'pie') {
|
|
427
431
|
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;
|
|
432
|
+
const polarLabels = { visible: 0, scaled: 0, overflowed: 0, suppressed: 0 };
|
|
428
433
|
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.' });
|
|
429
434
|
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' }); }
|
|
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,
|
|
435
|
+
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, 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 })); if (spec.labels?.enabled) { const label = formatValue(value / total, spec.labels.format || { style: 'percent' }, spec.locale), placement = polarLabelLayout(label, { cx, cy, outerRadius: radius, innerRadius: innerR, startAngle: angle, endAngle: end, size: fontSize(spec, 'label', 12), minSize: 10, padding: 4 }); addText(scene, `series-0-item-${index}-label`, label, placement.x, placement.y, { fill: readableLabelColor(spec, fill), font: fontAtSize(spec.labels.font || font(spec, 'label'), placement.size), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }, dataRef); polarLabels.visible += 1; if (placement.scaled) polarLabels.scaled += 1; if (placement.overflowed) polarLabels.overflowed += 1; } angle = end; });
|
|
436
|
+
state.labelLayout = { ...(state.labelLayout || {}), polar: polarLabels };
|
|
437
|
+
if (polarLabels.overflowed) data.warnings.push({ code: 'POLAR_LABEL_OVERFLOW', path: 'labels', count: polarLabels.overflowed, message: `${polarLabels.overflowed} pie label${polarLabels.overflowed === 1 ? '' : 's'} exceeded the available sector space and may overflow its sector.`, suggestion: 'Increase chart size, reduce the number of slices, or rely on the legend for small sectors.' });
|
|
431
438
|
} else if (spec.type === 'funnel') {
|
|
432
439
|
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
440
|
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), availableWidth = Math.max(labelSize * 2, width - 12), label = truncateText(category, availableWidth, labelSize); if (label !== category) data.warnings.push({ code: 'FUNNEL_LABEL_TRUNCATED', path: `data.values[${index}].${categoryField}`, index, rawLabel: category, renderedLabel: label, availableWidth, message: `Funnel stage label "${category}" was truncated to "${label}" because the stage is too narrow.`, suggestion: 'Increase chart width, shorten the stage label, or use a wider stage/value distribution.' }); 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); });
|
|
@@ -435,9 +442,14 @@ export function buildScene(spec) {
|
|
|
435
442
|
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;
|
|
436
443
|
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.' });
|
|
437
444
|
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.' });
|
|
438
|
-
|
|
445
|
+
const label = formatValue(numericValue, spec.labels?.format || spec.encoding.value?.format || { maximumFractionDigits: 2 }, spec.locale), placement = polarLabelLayout(label, { position: 'center', cx, cy, outerRadius: radius, size: fontSize(spec, 'metric', 24), minSize: 12, maxWidth: radius * 1.25, padding: 4, offsetRatio: 0.36 });
|
|
446
|
+
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', label, placement.x, placement.y, { fill: spec.theme.text, font: fontAtSize(font(spec, 'metric'), placement.size), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' });
|
|
447
|
+
state.labelLayout = { ...(state.labelLayout || {}), polar: { visible: 1, scaled: placement.scaled ? 1 : 0, overflowed: placement.overflowed ? 1 : 0, suppressed: 0 } };
|
|
448
|
+
if (placement.overflowed) data.warnings.push({ code: 'POLAR_LABEL_OVERFLOW', path: 'labels', count: 1, message: 'The Gauge value label exceeds the available body width.', suggestion: 'Increase chart size or reduce the metric font size.' });
|
|
439
449
|
}
|
|
440
450
|
addLegend(scene, spec, state.chrome.legend);
|
|
451
|
+
state.labelLayout = { ...(state.labelLayout || {}), marks: scene._labelLayout };
|
|
452
|
+
if (scene._labelLayout.suppressed) data.warnings.push({ code: 'LABELS_SUPPRESSED', path: 'labels', count: scene._labelLayout.suppressed, message: `${scene._labelLayout.suppressed} data labels were suppressed because there was no collision-free placement.`, suggestion: 'Increase chart size, reduce label density, or disable labels.' });
|
|
441
453
|
addBranding(scene, spec);
|
|
442
454
|
return { scene, data, state };
|
|
443
455
|
}
|
|
@@ -11,6 +11,7 @@ export const commandTypes = ['updateField', 'updateRecord', 'updateTask', 'shift
|
|
|
11
11
|
export const diagramOperations = ['moveNode', 'moveNodes', 'resizeNode', 'alignNodes', 'snapNodes', 'moveNodeToLane', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'removeEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'];
|
|
12
12
|
export const businessModels = ['project-task', 'timeline-event', 'milestone', 'burndown-sample', 'flow-node', 'flow-edge', 'swimlane', 'architecture-node', 'architecture-edge', 'mindmap-edge', 'mindmap-node'];
|
|
13
13
|
export const diagramEdgeModels = ['flow-edge', 'architecture-edge', 'mindmap-edge'];
|
|
14
|
+
export const flowNodeKinds = ['start', 'end', 'process', 'decision', 'io', 'connector'];
|
|
14
15
|
|
|
15
16
|
const status = (supported, notApplicable = []) => Object.fromEntries(supported.map(name => [name, 'supported']).concat(notApplicable.map(name => [name, 'not-applicable'])));
|
|
16
17
|
const commonPresentation = ['title', 'subtitle', 'theme', 'responsive', 'empty-state', 'invalid-data-state', 'export', 'branding'];
|
|
@@ -30,7 +31,7 @@ export const chartDefinitions = {
|
|
|
30
31
|
timeline: { family: 'project', intents: ['timeline', 'milestone'], required: ['date', 'title'], optional: ['status'], interactions: ['hover', 'tooltip', 'selection', 'zoom', 'pan', 'keyboard'], features: status([...commonPresentation, 'labels', 'formatting']) },
|
|
31
32
|
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
33
|
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
|
-
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
|
+
flow: { family: 'diagram', intents: ['workflow'], required: ['nodes'], optional: ['edges', 'groups', 'ports', 'semantic-node-kinds', 'connectors'], 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', 'semantic-shapes', 'decision-branches', 'connectors', 'loops', 'history'], ['axes', 'grid', 'legend']) },
|
|
34
35
|
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']) },
|
|
35
36
|
architecture: { family: 'diagram', intents: ['architecture', 'business-architecture', 'data-architecture', 'technical-architecture'], required: ['nodes'], optional: ['edges', 'layers', 'boundaries', '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', 'layers', 'boundaries', 'groups', 'ports', 'history'], ['axes', 'grid', 'legend']) },
|
|
36
37
|
mindmap: { family: 'diagram', intents: ['mindmap', 'hierarchy', 'brainstorm'], required: ['nodes'], optional: ['parentId', 'edges', 'groups'], interactions: ['hover', 'tooltip', 'selection', 'edge-selection', 'drag', 'edgeDrag', 'zoom', 'pan', 'keyboard', 'keyboard-edit', 'keyboard-port-connect', 'portConnect', 'copy-paste'], features: status([...commonPresentation, 'labels', 'routing', 'cubic-bezier', 'curve-tension', 'obstacle-fallback', 'manual-routing', 'layout', 'tree-layout', 'radial-layout', 'history'], ['axes', 'grid', 'legend']) }
|
|
@@ -55,5 +56,6 @@ export const contractRegistry = Object.freeze({
|
|
|
55
56
|
commandTypes: [...commandTypes],
|
|
56
57
|
diagramOperations: [...diagramOperations],
|
|
57
58
|
businessModels: [...businessModels],
|
|
58
|
-
diagramEdgeModels: [...diagramEdgeModels]
|
|
59
|
+
diagramEdgeModels: [...diagramEdgeModels],
|
|
60
|
+
flowNodeKinds: [...flowNodeKinds]
|
|
59
61
|
});
|
package/src/diagram.mjs
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { copyJSON } from './schema.mjs';
|
|
6
6
|
import { sampleCubicBezier } from './scene.mjs';
|
|
7
|
+
import { flowNodeKinds } from './contract-registry.mjs';
|
|
7
8
|
|
|
8
9
|
export const diagramLayoutModes = ['manual', 'layered', 'tree', 'radial'];
|
|
9
10
|
export const edgeRoutingModes = ['straight', 'orthogonal', 'curved'];
|
|
10
11
|
export const diagramModes = ['process', 'architecture', 'mindmap'];
|
|
12
|
+
export { flowNodeKinds };
|
|
11
13
|
|
|
12
14
|
const finite = value => typeof value === 'number' && Number.isFinite(value);
|
|
13
15
|
|
|
@@ -53,6 +55,7 @@ export function validateDiagram(spec = {}) {
|
|
|
53
55
|
else if (nodeIds.has(node.id)) errors.push({ code: 'DUPLICATE_NODE_ID', path: `nodes.${index}.id`, message: `Duplicate node ID: ${node.id}.` });
|
|
54
56
|
else nodeIds.add(node.id);
|
|
55
57
|
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.' });
|
|
58
|
+
if (normalized.type === 'flow' && node.kind !== undefined && !flowNodeKinds.includes(node.kind)) errors.push({ code: 'FLOW_NODE_KIND', path: `nodes.${index}.kind`, message: `Unsupported Flow node kind: ${node.kind}.`, expected: flowNodeKinds, suggestion: `Use one of: ${flowNodeKinds.join(', ')}.` });
|
|
56
59
|
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.' });
|
|
57
60
|
if (node.laneId && lanes.length && !laneIds.has(node.laneId)) errors.push({ code: 'MISSING_LANE', path: `nodes.${index}.laneId`, message: `Unknown lane ID: ${node.laneId}.` });
|
|
58
61
|
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.' });
|
|
@@ -88,6 +91,19 @@ export function validateDiagram(spec = {}) {
|
|
|
88
91
|
if (edge.curveTension !== undefined && (!finite(edge.curveTension) || edge.curveTension < 0.2 || edge.curveTension > 0.8)) errors.push({ code: 'EDGE_CURVE_TENSION', path: `edges.${index}.curveTension`, message: 'Edge curve tension must be a finite number between 0.2 and 0.8.' });
|
|
89
92
|
if (edge.waypoints !== undefined && (!Array.isArray(edge.waypoints) || edge.waypoints.some(point => !point || !finite(point.x) || !finite(point.y)))) errors.push({ code: 'EDGE_WAYPOINTS', path: `edges.${index}.waypoints`, message: 'Edge waypoints must be an array of finite x/y points.' });
|
|
90
93
|
});
|
|
94
|
+
if (normalized.type === 'flow') {
|
|
95
|
+
const semanticNodes = nodes.filter(node => node.kind !== undefined), outgoing = new Map(nodes.map(node => [node.id, []]));
|
|
96
|
+
edges.forEach(edge => { if (outgoing.has(edge.from)) outgoing.get(edge.from).push(edge); });
|
|
97
|
+
semanticNodes.forEach(node => {
|
|
98
|
+
const nodeIndex = nodes.indexOf(node), path = `nodes.${nodeIndex}.kind`;
|
|
99
|
+
if (node.kind === 'decision' && (outgoing.get(node.id) || []).length < 2) warnings.push({ code: 'FLOW_DECISION_BRANCHES', path, message: 'A decision node should have at least two outgoing branches.', suggestion: 'Add labeled edges such as label: "yes" and label: "no".' });
|
|
100
|
+
if (node.kind === 'connector' && !node.label) warnings.push({ code: 'FLOW_CONNECTOR_LABEL', path, message: 'An unlabeled connector is valid but harder to follow in a detached flow.', suggestion: 'Add a short label when the connector is used to join separated flow sections.' });
|
|
101
|
+
});
|
|
102
|
+
if (semanticNodes.length) {
|
|
103
|
+
if (!semanticNodes.some(node => node.kind === 'start')) warnings.push({ code: 'FLOW_MISSING_START', path: 'nodes', message: 'Semantic Flow has no start node.', suggestion: 'Add one node with kind: "start", or use explicit kind: "process" for a process-only Flow.' });
|
|
104
|
+
if (!semanticNodes.some(node => node.kind === 'end')) warnings.push({ code: 'FLOW_MISSING_END', path: 'nodes', message: 'Semantic Flow has no end node.', suggestion: 'Add one node with kind: "end", or use explicit kind: "process" for a process-only Flow.' });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
91
107
|
if (!diagramLayoutModes.includes(normalized.diagram.layout)) errors.push({ code: 'LAYOUT_MODE', path: 'diagram.layout', message: `Unsupported layout: ${normalized.diagram.layout}.` });
|
|
92
108
|
if (!finite(normalized.diagram.grid) || normalized.diagram.grid <= 0) errors.push({ code: 'DIAGRAM_GRID', path: 'diagram.grid', message: 'Grid must be a positive finite number.' });
|
|
93
109
|
if (!finite(normalized.diagram.curveTension) || normalized.diagram.curveTension < 0.2 || normalized.diagram.curveTension > 0.8) errors.push({ code: 'CURVE_TENSION', path: 'diagram.curveTension', message: 'Curve tension must be a finite number between 0.2 and 0.8.' });
|
package/src/index.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import { explainChart, getCapabilities as discoverCapabilities, getChartCapabili
|
|
|
23
23
|
import { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, normalizePreferences, validatePreferences } from './preferences.mjs';
|
|
24
24
|
import { mountChartSettings } from './preferences-ui.mjs';
|
|
25
25
|
import { ChartValidationError } from './errors.mjs';
|
|
26
|
-
import { diagramOperations } from './contract-registry.mjs';
|
|
26
|
+
import { diagramOperations, flowNodeKinds } from './contract-registry.mjs';
|
|
27
27
|
import { destroyChart } from './chart-lifecycle.mjs';
|
|
28
28
|
|
|
29
29
|
import { isDiagram, paintSelection, diagramPointer, diagramKeyboard } from './diagram-interaction.mjs';
|
|
@@ -762,7 +762,7 @@ export class Chart {
|
|
|
762
762
|
|
|
763
763
|
export function createChart(spec) { return new Chart(spec); }
|
|
764
764
|
export { ChartValidationError } from './errors.mjs';
|
|
765
|
-
export function getCapabilities() { const capabilities = discoverCapabilities(); return { ...capabilities, interactionDefaults: { ...capabilities.interactionDefaults }, diagram: { layoutModes: diagramLayoutModes, routingModes: edgeRoutingModes, entities: ['node', 'edge', 'lane', 'group', 'port', 'waypoint'], operations: [...diagramOperations], curvedEdges: { renderers: ['canvas', 'svg'], type: 'cubic-bezier', tension: { minimum: 0.2, maximum: 0.8, default: 0.4 }, mindmapDefault: true, obstacleFallback: 'orthogonal', controlPointEditing: false }, edgeEditing: { renderers: ['canvas', 'svg'], selection: true, waypointDrag: true, segmentDrag: true, persistentWaypoints: true, enabledByDefault: false, activation: ['editing.enabled', 'interaction.edgeDrag'] }, validation: ['duplicate-ids', 'missing-endpoints', 'missing-ports', 'missing-lanes', 'invalid-waypoints', 'invalid-curve-tension'] }, editing: { modes: ['preview', 'commit', 'undo', 'redo'], operations: [...capabilities.commands], confirmationRequiredByDefault: true, commit: 'local-runtime-host-persistence-required' } }; }
|
|
765
|
+
export function getCapabilities() { const capabilities = discoverCapabilities(); return { ...capabilities, interactionDefaults: { ...capabilities.interactionDefaults }, diagram: { layoutModes: diagramLayoutModes, routingModes: edgeRoutingModes, entities: ['node', 'edge', 'lane', 'group', 'port', 'waypoint'], operations: [...diagramOperations], flowNodeKinds: [...flowNodeKinds], flowBranchEdges: { label: 'edge.label', minimumOutgoing: 2 }, flowLoops: 'supported', flowConnectors: { kind: 'connector', linking: 'explicit from/to edges' }, curvedEdges: { renderers: ['canvas', 'svg'], type: 'cubic-bezier', tension: { minimum: 0.2, maximum: 0.8, default: 0.4 }, mindmapDefault: true, obstacleFallback: 'orthogonal', controlPointEditing: false }, edgeEditing: { renderers: ['canvas', 'svg'], selection: true, waypointDrag: true, segmentDrag: true, persistentWaypoints: true, enabledByDefault: false, activation: ['editing.enabled', 'interaction.edgeDrag'] }, validation: ['duplicate-ids', 'missing-endpoints', 'missing-ports', 'missing-lanes', 'invalid-waypoints', 'invalid-curve-tension'] }, editing: { modes: ['preview', 'commit', 'undo', 'redo'], operations: [...capabilities.commands], confirmationRequiredByDefault: true, commit: 'local-runtime-host-persistence-required' } }; }
|
|
766
766
|
export function recommend(input, options = {}) { const plan = planChart(input, options); return { primary: plan.primary, alternatives: plan.alternatives, reason: plan.reasons.join(' '), reasons: plan.reasons, confidence: plan.confidence, requiredFields: plan.requiredFields, assumptions: plan.assumptions, warnings: plan.warnings, nextActions: plan.nextActions }; }
|
|
767
767
|
export { normalizeSpec, validateSpec, normalizeData, inspectData, binData, applyTransforms, data, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, getChartCapability, getPreferenceCapabilities, planChart, explainChart };
|
|
768
768
|
export { diagramLayoutModes, diagramModes, edgeRoutingModes, normalizeDiagramSpec, validateDiagram, layoutDiagram, routeEdge };
|
|
@@ -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.19', 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/layout.mjs
CHANGED
|
@@ -48,6 +48,74 @@ export function truncateText(text, maxWidth, size = 12) {
|
|
|
48
48
|
return `${characters.slice(0, low).join('')}${suffix}`;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function wrapText(text, maxWidth, size) {
|
|
52
|
+
const characters = Array.from(String(text ?? ''));
|
|
53
|
+
if (!characters.length) return [''];
|
|
54
|
+
const lines = [];
|
|
55
|
+
let line = '';
|
|
56
|
+
characters.forEach(character => {
|
|
57
|
+
const candidate = `${line}${character}`;
|
|
58
|
+
if (line && estimateTextWidth(candidate, size) > maxWidth) {
|
|
59
|
+
lines.push(line.trimEnd());
|
|
60
|
+
line = character.trimStart();
|
|
61
|
+
} else line = candidate;
|
|
62
|
+
});
|
|
63
|
+
if (line || !lines.length) lines.push(line.trimEnd());
|
|
64
|
+
return lines;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function fitTextBlock(text, options = {}) {
|
|
68
|
+
const value = String(text ?? ''), maxWidth = Math.max(1, Number(options.maxWidth) || 1), maxHeight = Math.max(1, Number(options.maxHeight) || 1);
|
|
69
|
+
const baseSize = Math.max(1, Number(options.size) || 12), minSize = Math.max(1, Math.min(baseSize, Number(options.minSize) || 10));
|
|
70
|
+
const maxLines = Math.max(1, Math.floor(Number(options.maxLines) || 2)), lineHeightRatio = Math.max(1, Number(options.lineHeight) || 1.2);
|
|
71
|
+
for (let size = baseSize; size >= minSize; size -= 1) {
|
|
72
|
+
const lines = wrapText(value, maxWidth, size);
|
|
73
|
+
if (lines.length <= maxLines && lines.every(line => estimateTextWidth(line, size) <= maxWidth) && lines.length * size * lineHeightRatio <= maxHeight) {
|
|
74
|
+
return { lines, size, lineHeight: size * lineHeightRatio, width: Math.max(size, ...lines.map(line => estimateTextWidth(line, size))), height: lines.length * size * lineHeightRatio, wrapped: lines.length > 1, scaled: size < baseSize, truncated: false };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const lines = wrapText(value, maxWidth, minSize).slice(0, maxLines);
|
|
78
|
+
const visibleHeight = maxLines * minSize * lineHeightRatio;
|
|
79
|
+
if (visibleHeight > maxHeight) lines.splice(1);
|
|
80
|
+
const last = lines.length - 1;
|
|
81
|
+
lines[last] = truncateText(lines[last], maxWidth, minSize);
|
|
82
|
+
return { lines, size: minSize, lineHeight: minSize * lineHeightRatio, width: Math.max(minSize, ...lines.map(line => estimateTextWidth(line, minSize))), height: Math.min(maxHeight, Math.max(minSize, lines.length * minSize * lineHeightRatio)), wrapped: lines.length > 1, scaled: minSize < baseSize, truncated: lines.join('') !== value };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function polarLabelLayout(text, options = {}) {
|
|
86
|
+
const value = String(text ?? ''), baseSize = Math.max(1, Number(options.size) || 12), minSize = Math.max(1, Math.min(baseSize, Number(options.minSize) || 10));
|
|
87
|
+
const padding = Math.max(0, Number(options.padding) || 4), lineHeight = Math.max(1, Number(options.lineHeight) || 1.2), cx = Number(options.cx) || 0, cy = Number(options.cy) || 0;
|
|
88
|
+
const outerRadius = Math.max(1, Number(options.outerRadius) || 1), innerRadius = Math.max(0, Math.min(outerRadius, Number(options.innerRadius) || 0));
|
|
89
|
+
let size = baseSize, width = estimateTextWidth(value, size), maxWidth = Infinity, maxHeight = Infinity, x = cx, y = cy, angle = 0, radius = 0;
|
|
90
|
+
if (options.position === 'center') {
|
|
91
|
+
maxWidth = Math.max(1, Number(options.maxWidth) || outerRadius * 1.25);
|
|
92
|
+
maxHeight = Math.max(1, outerRadius * 0.68);
|
|
93
|
+
while (size > minSize && (width > maxWidth || size * lineHeight > maxHeight)) {
|
|
94
|
+
size -= 1;
|
|
95
|
+
width = estimateTextWidth(value, size);
|
|
96
|
+
}
|
|
97
|
+
const height = size * lineHeight;
|
|
98
|
+
const desiredY = cy - outerRadius * Number(options.offsetRatio ?? 0.36);
|
|
99
|
+
y = Math.max(cy - outerRadius + height / 2 + padding, Math.min(cy - height / 2 - padding, desiredY));
|
|
100
|
+
return { x, y, angle: -Math.PI / 2, radius: Math.abs(cy - y), size, width, height, scaled: size < baseSize, overflowed: width > maxWidth || height > maxHeight };
|
|
101
|
+
}
|
|
102
|
+
const startAngle = Number(options.startAngle) || 0, endAngle = Number(options.endAngle) || startAngle, sweep = Math.max(0, endAngle - startAngle), halfSweep = Math.min(Math.PI / 2, sweep / 2), centerAngle = startAngle + sweep / 2;
|
|
103
|
+
const preferredRadius = Number(options.preferredRadius) || (innerRadius > 0 ? (innerRadius + outerRadius) / 2 : outerRadius * 0.68);
|
|
104
|
+
radius = Math.max(innerRadius + padding + baseSize / 2, Math.min(outerRadius - padding - baseSize / 2, preferredRadius));
|
|
105
|
+
maxWidth = Math.max(1, 2 * radius * Math.sin(halfSweep) - padding * 2);
|
|
106
|
+
maxHeight = Math.max(1, outerRadius - innerRadius - padding * 2);
|
|
107
|
+
while (size > minSize && (width > maxWidth || size * lineHeight > maxHeight)) {
|
|
108
|
+
size -= 1;
|
|
109
|
+
width = estimateTextWidth(value, size);
|
|
110
|
+
}
|
|
111
|
+
const height = size * lineHeight;
|
|
112
|
+
radius = Math.max(innerRadius + padding + height / 2, Math.min(outerRadius - padding - height / 2, radius));
|
|
113
|
+
angle = centerAngle;
|
|
114
|
+
x = cx + Math.cos(angle) * radius;
|
|
115
|
+
y = cy + Math.sin(angle) * radius;
|
|
116
|
+
return { x, y, angle, radius, size, width, height, scaled: size < baseSize, overflowed: width > maxWidth || height > maxHeight };
|
|
117
|
+
}
|
|
118
|
+
|
|
51
119
|
export function axisLabelLayout(spec, labels = [], availableWidth = 0) {
|
|
52
120
|
const size = fontSize(spec, 'axis', 12), count = Math.max(1, labels.length);
|
|
53
121
|
const slot = Math.max(1, availableWidth / count), maxWidth = Math.max(size, ...labels.map(label => estimateTextWidth(label, size)));
|
package/src/project.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { layoutArchitectureNodes, layoutDiagram, normalizeDiagramData, routeEdge
|
|
|
8
8
|
import { analyzeBurndownSeries, analyzeSchedule } from './project-analytics.mjs';
|
|
9
9
|
import { createLinkedProjectState, filterProjectRows, linkedRecordId } from './project-linking.mjs';
|
|
10
10
|
import { contrastRatio, resolveTheme } from './theme.mjs';
|
|
11
|
-
import { axisLabelLayout, estimateTextWidth, fontSize, titleLayout, truncateText } from './layout.mjs';
|
|
11
|
+
import { axisLabelLayout, boxesOverlap, estimateTextWidth, fitTextBlock, fontSize, titleLayout, truncateText } from './layout.mjs';
|
|
12
12
|
|
|
13
13
|
export const projectTypes = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'];
|
|
14
14
|
const day = 86400000;
|
|
@@ -55,6 +55,34 @@ function projectDateDomain(spec, rows) {
|
|
|
55
55
|
const dependencyId = dependency => typeof dependency === 'string' ? dependency : dependency?.id || null;
|
|
56
56
|
const dependencyType = dependency => typeof dependency === 'object' && dependency?.type ? dependency.type : 'finish-to-start';
|
|
57
57
|
const markText = (theme, background) => (contrastRatio(theme.text, background) || 0) >= (contrastRatio(theme.background, background) || 0) ? theme.text : theme.background;
|
|
58
|
+
const flowNodeKind = node => node?.kind || 'process';
|
|
59
|
+
|
|
60
|
+
function diagramNodeLabelLayout(row, kind, geometry, spec, font = null) {
|
|
61
|
+
const size = font ? Number(String(font).match(/(\d+(?:\.\d+)?)px/)?.[1]) || fontSize(spec, 'label', 12) : fontSize(spec, 'label', 12);
|
|
62
|
+
const minDimension = Math.min(geometry.width, geometry.height);
|
|
63
|
+
const horizontalPadding = kind === 'decision' ? size * 1.8 : size * 1.25;
|
|
64
|
+
const maxWidth = Math.max(0, geometry.width - horizontalPadding * 2);
|
|
65
|
+
if (minDimension < Math.max(24, size * 2) || maxWidth < size * 2.4) return null;
|
|
66
|
+
return fitTextBlock(row.label || row.id, { maxWidth, maxHeight: Math.max(size, geometry.height - size * 0.45), size, minSize: Math.max(10, Math.min(size, size - 2)), maxLines: 2 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function flowNodeVisual(kind, box) {
|
|
70
|
+
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
|
|
71
|
+
if (kind === 'start' || kind === 'end') return { type: 'path', geometry: { points: Array.from({ length: 16 }, (_, index) => { const angle = index / 16 * Math.PI * 2; return { x: cx + box.width / 2 * Math.cos(angle), y: cy + box.height / 2 * Math.sin(angle) }; }), closed: true } };
|
|
72
|
+
if (kind === 'decision') return { type: 'path', geometry: { points: [{ x: cx, y: box.y }, { x: box.x + box.width, y: cy }, { x: cx, y: box.y + box.height }, { x: box.x, y: cy }], closed: true } };
|
|
73
|
+
if (kind === 'io') {
|
|
74
|
+
const skew = Math.min(18, box.width * 0.18);
|
|
75
|
+
return { type: 'path', geometry: { points: [{ x: box.x + skew, y: box.y }, { x: box.x + box.width, y: box.y }, { x: box.x + box.width - skew, y: box.y + box.height }, { x: box.x, y: box.y + box.height }], closed: true } };
|
|
76
|
+
}
|
|
77
|
+
if (kind === 'connector') return { type: 'circle', geometry: { cx, cy, r: Math.min(box.width, box.height) / 2 } };
|
|
78
|
+
return { type: 'rect', geometry: box };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function flowNodeBox(row, generated, fallback) {
|
|
82
|
+
const kind = flowNodeKind(row), connector = kind === 'connector';
|
|
83
|
+
const width = Number(row.size?.width) || (connector ? 28 : 112), height = Number(row.size?.height) || (connector ? 28 : 36);
|
|
84
|
+
return { x: row.position?.x ?? generated?.x ?? fallback.x, y: row.position?.y ?? generated?.y ?? fallback.y, width, height };
|
|
85
|
+
}
|
|
58
86
|
|
|
59
87
|
function compactRoute(points) {
|
|
60
88
|
return points.filter((point, index) => {
|
|
@@ -84,8 +112,56 @@ function routeGanttDependency(dependency, from, to, positions, barOffset, plot)
|
|
|
84
112
|
return compactRoute([source, { x: sourceStubX, y: source.y }, { x: sourceStubX, y: corridorY }, { x: targetStubX, y: corridorY }, { x: targetStubX, y: target.y }, target]);
|
|
85
113
|
}
|
|
86
114
|
|
|
87
|
-
function text(scene, id, content, x, y, style = {}) {
|
|
88
|
-
scene.add({ id, type: 'text', geometry: { text: String(content), x, y }, style: { fill: scene.theme?.text || '#334155', font: scene.theme?.typography?.label?.font || '12px system-ui', ...style }, zIndex: 4 });
|
|
115
|
+
function text(scene, id, content, x, y, style = {}, dataRef = null) {
|
|
116
|
+
scene.add({ id, type: 'text', geometry: { text: String(content), x, y }, style: { fill: scene.theme?.text || '#334155', font: scene.theme?.typography?.label?.font || '12px system-ui', ...style }, dataRef, zIndex: 4 });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function textBlock(scene, id, block, x, y, style = {}, dataRef = null) {
|
|
120
|
+
if (!block) return;
|
|
121
|
+
const start = y - (block.lines.length - 1) * block.lineHeight / 2;
|
|
122
|
+
block.lines.forEach((line, index) => text(scene, index ? `${id}-${index}` : id, line, x, start + index * block.lineHeight, { textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle', ...style }, dataRef));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function labelPlate(scene, id, box, fill, opacity = 0.94) {
|
|
126
|
+
scene.add({ id: `${id}-background`, type: 'rect', geometry: { x: box.x - 3, y: box.y - 2, width: box.width + 6, height: box.height + 4 }, style: { fill, opacity, stroke: 'none' }, zIndex: 3 });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function polylineMidpoint(points) {
|
|
130
|
+
if (!points?.length) return null;
|
|
131
|
+
if (points.length === 1) return { point: points[0], start: points[0], end: points[0] };
|
|
132
|
+
const lengths = points.slice(1).map((point, index) => Math.hypot(point.x - points[index].x, point.y - points[index].y));
|
|
133
|
+
const target = lengths.reduce((sum, value) => sum + value, 0) / 2;
|
|
134
|
+
let traversed = 0;
|
|
135
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
136
|
+
const length = lengths[index - 1];
|
|
137
|
+
if (traversed + length >= target) {
|
|
138
|
+
const ratio = length ? (target - traversed) / length : 0;
|
|
139
|
+
return { point: { x: points[index - 1].x + (points[index].x - points[index - 1].x) * ratio, y: points[index - 1].y + (points[index].y - points[index - 1].y) * ratio }, start: points[index - 1], end: points[index] };
|
|
140
|
+
}
|
|
141
|
+
traversed += length;
|
|
142
|
+
}
|
|
143
|
+
return { point: points.at(-1), start: points.at(-2), end: points.at(-1) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function edgeLabelLayout(edge, spec, positions, occupied = []) {
|
|
147
|
+
const points = edge.points, midpoint = edge.curve === 'cubic' ? { point: cubicBezierPoint(points, 0.5), start: cubicBezierPoint(points, 0.45), end: cubicBezierPoint(points, 0.55) } : polylineMidpoint(points);
|
|
148
|
+
if (!midpoint) return null;
|
|
149
|
+
const length = Math.max(1, edge.curve === 'cubic' ? Math.hypot(points.at(-1).x - points[0].x, points.at(-1).y - points[0].y) : Math.hypot(midpoint.end.x - midpoint.start.x, midpoint.end.y - midpoint.start.y));
|
|
150
|
+
const angle = Math.atan2(midpoint.end.y - midpoint.start.y, midpoint.end.x - midpoint.start.x), normal = { x: -Math.sin(angle), y: Math.cos(angle) }, size = fontSize(spec, 'axis', 12);
|
|
151
|
+
const block = fitTextBlock(edge.label, { maxWidth: Math.max(size * 3, Math.min(180, length - 10)), maxHeight: size * 2.6, size, minSize: 10, maxLines: 2 });
|
|
152
|
+
const inlineOffset = 8, candidates = [
|
|
153
|
+
{ x: midpoint.point.x, y: midpoint.point.y - inlineOffset, onLine: true },
|
|
154
|
+
{ x: midpoint.point.x + normal.x * (inlineOffset + block.height + 6), y: midpoint.point.y + normal.y * (inlineOffset + block.height + 6), onLine: false },
|
|
155
|
+
{ x: midpoint.point.x - normal.x * (inlineOffset + block.height + 6), y: midpoint.point.y - normal.y * (inlineOffset + block.height + 6), onLine: false },
|
|
156
|
+
{ x: midpoint.point.x + normal.x * (inlineOffset + 2 * (block.height + 6)), y: midpoint.point.y + normal.y * (inlineOffset + 2 * (block.height + 6)), onLine: false },
|
|
157
|
+
{ x: midpoint.point.x - normal.x * (inlineOffset + 2 * (block.height + 6)), y: midpoint.point.y - normal.y * (inlineOffset + 2 * (block.height + 6)), onLine: false }
|
|
158
|
+
];
|
|
159
|
+
const nodeBoxes = [...positions.values()];
|
|
160
|
+
for (const candidate of candidates) {
|
|
161
|
+
const box = { x: candidate.x - block.width / 2, y: candidate.y - block.height / 2, width: block.width, height: block.height };
|
|
162
|
+
if (!nodeBoxes.some(node => boxesOverlap(box, node, 2)) && !occupied.some(item => boxesOverlap(box, item, 3))) return { ...candidate, anchor: midpoint.point, box, block };
|
|
163
|
+
}
|
|
164
|
+
return { ...candidates[0], anchor: midpoint.point, box: { x: candidates[0].x - block.width / 2, y: candidates[0].y - block.height / 2, width: block.width, height: block.height }, block, suppressed: false };
|
|
89
165
|
}
|
|
90
166
|
|
|
91
167
|
function arrow(scene, id, points, reference, critical = false, curve = null) {
|
|
@@ -325,6 +401,9 @@ function diagramScene(scene, spec, rows, state) {
|
|
|
325
401
|
const columns = Math.max(1, ...[...ranks.values()].map(rank => rank + 1));
|
|
326
402
|
const gapX = Math.max(144, plot.width / columns), laneHeight = Math.max(80, plot.height / Math.max(1, lanes.length));
|
|
327
403
|
const positions = new Map(), slots = new Map();
|
|
404
|
+
const edgeLabelBoxes = [], labelLayout = { visible: 0, wrapped: 0, scaled: 0, truncated: 0, suppressed: 0, edgeOnLine: 0, edgeOffset: 0, backgrounded: 0 };
|
|
405
|
+
state.labelLayout = { ...(state.labelLayout || {}), diagram: labelLayout };
|
|
406
|
+
state.labelWarnings = [];
|
|
328
407
|
lanes.forEach((lane, index) => {
|
|
329
408
|
scene.add({ id: `lane-${index}`, type: 'rect', geometry: { x: plot.x, y: plot.y + index * laneHeight, width: Math.max(plot.width, columns * gapX), height: laneHeight }, style: { fill: index % 2 ? spec.theme.surface : spec.theme.background, stroke: spec.theme.border }, zIndex: -1 });
|
|
330
409
|
text(scene, `lane-label-${index}`, lane.label || lane.id, plot.x - 12, plot.y + index * laneHeight + 20, { textAnchor: 'end' });
|
|
@@ -345,17 +424,29 @@ function diagramScene(scene, spec, rows, state) {
|
|
|
345
424
|
const architectureBox = architectureLayout[row.id];
|
|
346
425
|
const geometry = architectureBox
|
|
347
426
|
? { ...architectureBox, x: row.position?.x ?? architectureBox.x, y: row.position?.y ?? architectureBox.y }
|
|
348
|
-
: {
|
|
427
|
+
: flowNodeBox(row, lanes.length ? { ...generated, y: undefined } : generated, { x: plot.x + rank * gapX + 12, y: plot.y + (lanes.length ? lane * laneHeight : 0) + (slot + 0.5) * (lanes.length ? laneHeight : Math.max(plot.height, sameCell * 64)) / sameCell - (flowNodeKind(row) === 'connector' ? 14 : 18) });
|
|
349
428
|
positions.set(row.id, geometry);
|
|
350
429
|
if (collapsedGroups.has(row.groupId)) return;
|
|
351
430
|
const depth = row.parentId ? (ranks.get(row.id) || 0) : 0;
|
|
352
431
|
const colorIndex = mode === 'mindmap' ? depth % spec.colors.length : lane >= 0 ? lane % spec.colors.length : index % spec.colors.length;
|
|
353
|
-
|
|
432
|
+
const kind = mode === 'process' && spec.type === 'flow' ? flowNodeKind(row) : 'process', visual = flowNodeVisual(kind, geometry);
|
|
433
|
+
scene.add({ id: `node-${row.id}`, type: visual.type, geometry: visual.geometry, bounds: { ...geometry }, style: { fill: spec.colors[colorIndex], stroke: row.root || mode === 'mindmap' && !row.parentId ? spec.theme.focus : spec.theme.background, strokeWidth: row.root || mode === 'mindmap' && !row.parentId ? 2 : 1 }, dataRef: { nodeId: row.id, kind, dataIndex: index, datum: row, groupId: row.groupId || null, layerId: row.layerId || null, parentId: row.parentId || null }, interactive: true, zIndex: 2 });
|
|
354
434
|
if (spec.editing?.enabled && spec.interaction?.portConnect) (row.ports || []).forEach(port => {
|
|
355
435
|
const point = port.side === 'left' ? { x: geometry.x, y: geometry.y + geometry.height * (port.offset ?? 0.5) } : port.side === 'top' ? { x: geometry.x + geometry.width * (port.offset ?? 0.5), y: geometry.y } : port.side === 'bottom' ? { x: geometry.x + geometry.width * (port.offset ?? 0.5), y: geometry.y + geometry.height } : { x: geometry.x + geometry.width, y: geometry.y + geometry.height * (port.offset ?? 0.5) };
|
|
356
436
|
scene.add({ id: `port-${row.id}-${port.id}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 6, y: point.y - 6, width: 12, height: 12 }, style: { fill: spec.theme.background, stroke: spec.theme.text, strokeWidth: 1.5 }, dataRef: { nodeId: row.id, portId: port.id, groupId: row.groupId || null }, interactive: true, zIndex: 4 });
|
|
357
437
|
});
|
|
358
|
-
|
|
438
|
+
const label = diagramNodeLabelLayout(row, kind, geometry, spec, nodeFont);
|
|
439
|
+
if (label) {
|
|
440
|
+
const rawLabel = String(row.label || row.id), labelFont = nodeFont ? nodeFont.replace(/\d+(?:\.\d+)?px/, `${label.size}px`) : `${label.size}px system-ui`;
|
|
441
|
+
textBlock(scene, `node-label-${row.id}`, label, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2, { fill: markText(spec.theme, spec.colors[lane % spec.colors.length]), font: labelFont }, { nodeId: row.id, rawLabel, renderedLabel: label.lines.join('\n'), truncated: label.truncated });
|
|
442
|
+
labelLayout.visible += 1;
|
|
443
|
+
if (label.wrapped) labelLayout.wrapped += 1;
|
|
444
|
+
if (label.scaled) labelLayout.scaled += 1;
|
|
445
|
+
if (label.truncated) { labelLayout.truncated += 1; state.labelWarnings.push({ code: 'LABEL_TRUNCATED', path: `nodes.${index}.label`, nodeId: row.id, rawLabel, renderedLabel: label.lines.join('\n'), message: `Node label "${rawLabel}" was truncated to fit its shape.`, suggestion: 'Increase node size or shorten the label.' }); }
|
|
446
|
+
} else if (kind !== 'connector') {
|
|
447
|
+
labelLayout.suppressed += 1;
|
|
448
|
+
state.labelWarnings.push({ code: 'LABELS_SUPPRESSED', path: `nodes.${index}.label`, count: 1, nodeId: row.id, message: `Node label for "${row.label || row.id}" was hidden because the node is too small.`, suggestion: 'Increase node size or use the node tooltip/accessibility text.' });
|
|
449
|
+
}
|
|
359
450
|
});
|
|
360
451
|
const groupBoxes = new Map();
|
|
361
452
|
groups.forEach(group => {
|
|
@@ -394,8 +485,17 @@ function diagramScene(scene, spec, rows, state) {
|
|
|
394
485
|
const points = path.points;
|
|
395
486
|
arrow(scene, `edge-${index}`, points, { from: edge.from, to: edge.to, edgeId: edge.id || `edge-${index}`, status: edge.status, routing, curveTension: edge.curveTension ?? diagramSpec.diagram.curveTension, waypoints: edge.waypoints || [], fromPortDefinition, toPortDefinition, fromGroupId: fromNode.groupId || null, toGroupId: toNode.groupId || null }, edge.critical === true, path.curve);
|
|
396
487
|
if (edge.label) {
|
|
397
|
-
const
|
|
398
|
-
|
|
488
|
+
const placement = edgeLabelLayout({ ...edge, points, curve: path.curve }, spec, positions, edgeLabelBoxes);
|
|
489
|
+
if (placement) {
|
|
490
|
+
edgeLabelBoxes.push(placement.box);
|
|
491
|
+
const rawLabel = String(edge.label), labelFont = `${placement.block.size}px system-ui`;
|
|
492
|
+
if (placement.onLine) labelLayout.edgeOnLine += 1; else labelLayout.edgeOffset += 1;
|
|
493
|
+
if (placement.block.wrapped) labelLayout.wrapped += 1;
|
|
494
|
+
if (placement.block.scaled) labelLayout.scaled += 1;
|
|
495
|
+
if (placement.block.truncated) { labelLayout.truncated += 1; state.labelWarnings.push({ code: 'LABEL_TRUNCATED', path: `edges.${index}.label`, edgeId: edge.id || `edge-${index}`, rawLabel, renderedLabel: placement.block.lines.join('\n'), message: `Edge label "${rawLabel}" was truncated to fit the available line space.`, suggestion: 'Increase the edge length or shorten the label.' }); }
|
|
496
|
+
if (placement.onLine) { labelPlate(scene, `edge-label-${index}`, placement.box, spec.theme.background); labelLayout.backgrounded += 1; }
|
|
497
|
+
textBlock(scene, `edge-label-${index}`, placement.block, placement.x, placement.y, { fill: spec.theme.text, font: labelFont }, { edgeId: edge.id || `edge-${index}`, rawLabel, renderedLabel: placement.block.lines.join('\n'), onLine: placement.onLine, offsetX: placement.x - placement.anchor.x, offsetY: placement.y - placement.anchor.y });
|
|
498
|
+
}
|
|
399
499
|
}
|
|
400
500
|
});
|
|
401
501
|
state.nodePositions = Object.fromEntries(positions);
|
|
@@ -441,7 +541,10 @@ export function buildProjectScene(spec) {
|
|
|
441
541
|
state.projectAnalytics.warnings = state.schedule.warnings;
|
|
442
542
|
}
|
|
443
543
|
if (!data.rows.length) text(scene, 'empty', sourceRows.length && linked.visibleCount === 0 ? 'No matching data' : spec.emptyText || 'No data', spec.width / 2, spec.height / 2, { textAnchor: 'middle' });
|
|
444
|
-
else if (diagram)
|
|
544
|
+
else if (diagram) {
|
|
545
|
+
diagramScene(scene, spec, data.rows, state);
|
|
546
|
+
if (state.labelWarnings?.length) data.warnings.push(...state.labelWarnings);
|
|
547
|
+
}
|
|
445
548
|
else if (spec.type === 'burndown') burndownScene(scene, spec, data.rows, state);
|
|
446
549
|
else tasksScene(scene, spec, data.rows, state);
|
|
447
550
|
if (state.eventLayout?.collisionsUnresolved) data.warnings.push({
|
|
@@ -495,7 +598,7 @@ export function projectTooltip(type, row, locale = 'en-US') {
|
|
|
495
598
|
|
|
496
599
|
export function rerouteDiagramScene(scene) {
|
|
497
600
|
const nodeGeometries = new Map();
|
|
498
|
-
scene.walk(item => { if (item.id.startsWith('node-') && item.dataRef?.nodeId && item.geometry) nodeGeometries.set(item.dataRef.nodeId, item.geometry); });
|
|
601
|
+
scene.walk(item => { if (item.id.startsWith('node-') && !item.id.startsWith('node-label-') && item.dataRef?.nodeId && item.geometry) nodeGeometries.set(item.dataRef.nodeId, item.geometry); });
|
|
499
602
|
scene.walk(node => {
|
|
500
603
|
if (!node.id.startsWith('edge-') || !node.dataRef?.from || !node.dataRef?.to) return;
|
|
501
604
|
const source = nodeGeometries.get(node.dataRef.from), destination = nodeGeometries.get(node.dataRef.to);
|
|
@@ -509,7 +612,13 @@ export function rerouteDiagramScene(scene) {
|
|
|
509
612
|
const arrowNode = scene.find(`${node.id}-arrow`), beforeTip = node.geometry.points.at(-2);
|
|
510
613
|
if (arrowNode) arrowNode.geometry.points = [end, { x: end.x - 7 * Math.cos(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) - Math.PI / 6), y: end.y - 7 * Math.sin(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) - Math.PI / 6) }, { x: end.x - 7 * Math.cos(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) + Math.PI / 6), y: end.y - 7 * Math.sin(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) + Math.PI / 6) }, end];
|
|
511
614
|
const edgeIndex = Number(node.id.slice(5)), label = scene.find(`edge-label-${edgeIndex}`), labelPoint = node.geometry.curve === 'cubic' ? cubicBezierPoint(node.geometry.points, 0.5) : node.geometry.points[Math.floor(node.geometry.points.length / 2)];
|
|
512
|
-
if (label && labelPoint) {
|
|
615
|
+
if (label && labelPoint) {
|
|
616
|
+
const old = { x: label.geometry.x, y: label.geometry.y }, offsetX = Number(label.dataRef?.offsetX) || 0, offsetY = Number(label.dataRef?.offsetY) || 0;
|
|
617
|
+
label.geometry.x = labelPoint.x + offsetX; label.geometry.y = labelPoint.y + offsetY;
|
|
618
|
+
const background = scene.find(`edge-label-${edgeIndex}-background`), dx = label.geometry.x - old.x, dy = label.geometry.y - old.y;
|
|
619
|
+
if (background) { background.geometry.x += dx; background.geometry.y += dy; }
|
|
620
|
+
for (let lineIndex = 1; ; lineIndex += 1) { const line = scene.find(`edge-label-${edgeIndex}-${lineIndex}`); if (!line) break; line.geometry.x += dx; line.geometry.y += dy; }
|
|
621
|
+
}
|
|
513
622
|
});
|
|
514
623
|
return scene;
|
|
515
624
|
}
|
package/src/schema.mjs
CHANGED
|
@@ -72,7 +72,7 @@ const models = {
|
|
|
72
72
|
'timeline-event': schema('timeline-event', { title: { type: 'string', required: true, editable: true }, date: { type: 'date', required: true, editable: true }, description: { type: 'string', editable: true }, status }),
|
|
73
73
|
milestone: schema('milestone', { title: { type: 'string', required: true, editable: true }, date: { type: 'date', required: true, editable: true }, status }),
|
|
74
74
|
'burndown-sample': schema('burndown-sample', { date: { type: 'date', required: true, editable: true }, remaining: { type: 'number', min: 0, required: true, editable: true }, scopeChange: { type: 'number', default: 0, editable: true }, forecast: { type: 'date', nullable: true, editable: false } }),
|
|
75
|
-
'flow-node': schema('flow-node', { label: { type: 'string', required: true, editable: true }, position, size: { type: 'object', editable: true, properties: { width: { type: 'number', required: true, min: 1 }, height: { type: 'number', required: true, min: 1 } } }, laneId: { type: 'string', references: 'swimlane', editable: true }, groupId: { type: 'string', editable: false }, ports: { type: 'array', editable: false, items: { type: 'object', properties: { id: { type: 'string', required: true }, side: { type: 'enum', values: ['left', 'right', 'top', 'bottom'] }, offset: { type: 'number', min: 0, max: 1 } } } }, description: { type: 'string', editable: true }, status }),
|
|
75
|
+
'flow-node': schema('flow-node', { label: { type: 'string', required: true, editable: true }, kind: { type: 'enum', values: ['start', 'end', 'process', 'decision', 'io', 'connector'], default: 'process', editable: true }, position, size: { type: 'object', editable: true, properties: { width: { type: 'number', required: true, min: 1 }, height: { type: 'number', required: true, min: 1 } } }, laneId: { type: 'string', references: 'swimlane', editable: true }, groupId: { type: 'string', editable: false }, ports: { type: 'array', editable: false, items: { type: 'object', properties: { id: { type: 'string', required: true }, side: { type: 'enum', values: ['left', 'right', 'top', 'bottom'] }, offset: { type: 'number', min: 0, max: 1 } } } }, description: { type: 'string', editable: true }, status }),
|
|
76
76
|
'flow-edge': schema('flow-edge', {
|
|
77
77
|
from: { type: 'string', references: 'flow-node', required: true, editable: true },
|
|
78
78
|
to: { type: 'string', references: 'flow-node', required: true, editable: true },
|
package/types/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type Renderer = 'canvas' | 'svg' | 'auto';
|
|
2
2
|
export type ChartType = 'line' | 'area' | 'bar' | 'column' | 'pie' | 'scatter' | 'funnel' | 'gauge' | 'heatmap' | 'radar' | 'gantt' | 'timeline' | 'milestone' | 'burndown' | 'flow' | 'swimlane' | 'architecture' | 'mindmap';
|
|
3
|
+
export type FlowNodeKind = 'start' | 'end' | 'process' | 'decision' | 'io' | 'connector';
|
|
3
4
|
export type ThemeMode = 'auto' | 'light' | 'dark' | 'contrast';
|
|
4
5
|
export type ThemePreset = 'auto' | 'analysis' | 'dashboard' | 'report' | 'presentation' | 'project' | 'diagram';
|
|
5
6
|
export type ThemePalette = 'auto' | 'categorical' | 'sequential' | 'diverging' | 'status';
|
|
@@ -25,7 +26,7 @@ export class ChartValidationError extends Error { name: 'ChartValidationError';
|
|
|
25
26
|
export interface DataFieldInfo { name: string; type: 'quantitative' | 'temporal' | 'category' | 'unknown'; role: 'identifier' | 'measure' | 'temporal-dimension' | 'dimension'; unit: string | null; cardinality: number; validCount: number; nullCount: number; min?: number; max?: number; temporalMin?: string; temporalMax?: string; }
|
|
26
27
|
export interface DataInspection { version: '1.0'; rows: number; fields: DataFieldInfo[]; dimensions: string[]; measures: string[]; temporalFields: string[]; missingValueCount: number; warnings: Diagnostic[]; }
|
|
27
28
|
export interface ChartCapability { type: ChartType; family: string; intents: string[]; required: string[]; optional: string[]; dataShapes: string[]; interactions: string[]; features: Record<string, 'supported' | 'not-applicable' | 'degraded'>; renderers: Array<'canvas' | 'svg'>; exports: string[]; limits: Record<string, number>; }
|
|
28
|
-
export interface RuntimeCapabilities { version: '2.0'; contractVersion: '1.1'; chartTypes: ChartType[]; charts: Record<ChartType, ChartCapability>; intents: string[]; locale?: { default: string; recommended: string[]; appliesTo: string[]; inputDates: string }; renderers: Array<'canvas' | 'svg'>; interactions: string[]; exports: Array<'png' | 'svg' | 'json' | 'jpeg'>; styleSystem: { modes: ThemeMode[]; presets: ThemePreset[]; palettes: ThemePalette[]; switchable: boolean; automatic: boolean; [key: string]: unknown }; preferences?: { version: '1.0'; scopes: string[]; persistence: string[]; fields: string[]; agentAdjustable: boolean; interactiveSettingsUI: boolean; precedence: string[]; schema: PreferenceCapabilities }; headless: { preview?: boolean; json: boolean; svg: boolean; png: boolean | string }; export: { types: string[]; mime: Record<string, string>; browser: Record<string, boolean>; headless: Record<string, string | boolean>; methods: string[]; options: Record<string, unknown>; branding: Record<string, unknown> }; branding: { defaultEnabled: boolean; signature: string; options: Record<string, unknown> }; [key: string]: unknown; }
|
|
29
|
+
export interface RuntimeCapabilities { version: '2.0'; contractVersion: '1.1'; chartTypes: ChartType[]; charts: Record<ChartType, ChartCapability>; intents: string[]; locale?: { default: string; recommended: string[]; appliesTo: string[]; inputDates: string }; renderers: Array<'canvas' | 'svg'>; interactions: string[]; exports: Array<'png' | 'svg' | 'json' | 'jpeg'>; chartModes?: Record<string, unknown>; diagram?: { flowNodeKinds?: FlowNodeKind[]; flowBranchEdges?: { label: string; minimumOutgoing: number }; flowLoops?: string; flowConnectors?: { kind: FlowNodeKind; linking: string }; [key: string]: unknown }; styleSystem: { modes: ThemeMode[]; presets: ThemePreset[]; palettes: ThemePalette[]; switchable: boolean; automatic: boolean; [key: string]: unknown }; preferences?: { version: '1.0'; scopes: string[]; persistence: string[]; fields: string[]; agentAdjustable: boolean; interactiveSettingsUI: boolean; precedence: string[]; schema: PreferenceCapabilities }; headless: { preview?: boolean; json: boolean; svg: boolean; png: boolean | string }; export: { types: string[]; mime: Record<string, string>; browser: Record<string, boolean>; headless: Record<string, string | boolean>; methods: string[]; options: Record<string, unknown>; branding: Record<string, unknown> }; branding: { defaultEnabled: boolean; signature: string; options: Record<string, unknown> }; [key: string]: unknown; }
|
|
29
30
|
export interface ChartPlan { version: '1.0'; intent: string; intentKnown?: boolean; intentSuggestions?: string[]; fallbackUsed?: boolean; primary: ChartType; alternatives: ChartType[]; confidence: number; reasons: string[]; requiredFields: string[]; suggestedEncodings: { dimension: string | null; measure: string | null; secondaryMeasure: string | null }; assumptions: string[]; warnings: Diagnostic[]; unsupportedRequests: string[]; nextActions: string[]; capability: ChartCapability; styleRecommendation: StyleRecommendation; data: DataInspection; }
|
|
30
31
|
export interface AxisState { rawDomain: [number, number]; domain: [number, number]; ticks: number[]; step: number | null; policy: 'nice' | 'raw' | 'explicit'; }
|
|
31
32
|
export interface TimeAxisState { orientation: 'horizontal'; field: 'date'; coordinate: 'x'; rowCoordinate: 'y'; domain: [number, number]; domainISO: [string, string]; }
|
|
@@ -35,20 +36,76 @@ export interface ChartInteraction { tooltip?: boolean; hover?: boolean; click?:
|
|
|
35
36
|
export interface ChartEditing { enabled?: boolean; mode?: 'command' | string; requireConfirmation?: boolean; allowDelete?: boolean; allowStructuralChanges?: boolean; }
|
|
36
37
|
export interface ChartTitle { text?: string; subtitle?: string; label?: string; }
|
|
37
38
|
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; }
|
|
38
|
-
export interface
|
|
39
|
+
export interface ChartPadding { top?: number; right?: number; bottom?: number; left?: number; }
|
|
40
|
+
export interface EncodingSpec { field?: string; type?: string; aggregate?: string; stack?: string | boolean; [key: string]: unknown; }
|
|
41
|
+
export interface ChartData { values?: Array<Record<string, unknown>>; nodes?: DiagramNode[]; edges?: DiagramEdge[]; lanes?: DiagramLane[]; layers?: ArchitectureLayer[]; boundaries?: ArchitectureBoundary[]; groups?: DiagramGroup[]; schema?: BusinessDataSchema; [key: string]: unknown; }
|
|
42
|
+
export interface ChartLegend { visible?: boolean; position?: 'top' | 'bottom' | 'left' | 'right' | string; [key: string]: unknown; }
|
|
43
|
+
export interface ChartLabels { enabled?: boolean; format?: string | Record<string, unknown>; color?: string; font?: string; position?: 'inside' | 'outside' | string; [key: string]: unknown; }
|
|
44
|
+
export interface ColorScaleSpec { domain?: [number, number]; range?: string[]; missing?: string; [key: string]: unknown; }
|
|
45
|
+
export interface StackSpec { mode: 'stacked' | 'percent'; [key: string]: unknown; }
|
|
46
|
+
export interface ChartProjectSpec { calendar?: ProjectCalendarConfig; linked?: { filters?: LinkedFilters; selection?: string[] }; [key: string]: unknown; }
|
|
47
|
+
export interface ChartSpec {
|
|
48
|
+
version?: string;
|
|
49
|
+
type: ChartType;
|
|
50
|
+
renderer?: Renderer;
|
|
51
|
+
container?: string | Element;
|
|
52
|
+
chartId?: string;
|
|
53
|
+
width?: number;
|
|
54
|
+
height?: number;
|
|
55
|
+
padding?: ChartPadding;
|
|
56
|
+
colors?: string[];
|
|
57
|
+
background?: string;
|
|
58
|
+
locale?: string;
|
|
59
|
+
data?: Array<Record<string, unknown>> | ChartData;
|
|
60
|
+
encoding?: Record<string, EncodingSpec | EncodingSpec[]>;
|
|
61
|
+
title?: string | ChartTitle;
|
|
62
|
+
legend?: ChartLegend;
|
|
63
|
+
grid?: { visible?: boolean; color?: string; [key: string]: unknown };
|
|
64
|
+
labels?: ChartLabels;
|
|
65
|
+
xAxis?: AxisSpec;
|
|
66
|
+
yAxis?: AxisSpec;
|
|
67
|
+
domain?: [number, number];
|
|
68
|
+
colorScale?: ColorScaleSpec;
|
|
69
|
+
indicators?: RadarIndicator[];
|
|
70
|
+
stack?: 'stacked' | 'percent' | StackSpec;
|
|
71
|
+
transform?: BinTransform | BinTransform[];
|
|
72
|
+
criticalPath?: boolean | string[];
|
|
73
|
+
nodes?: DiagramNode[];
|
|
74
|
+
edges?: DiagramEdge[];
|
|
75
|
+
lanes?: DiagramLane[];
|
|
76
|
+
layers?: ArchitectureLayer[];
|
|
77
|
+
boundaries?: ArchitectureBoundary[];
|
|
78
|
+
groups?: DiagramGroup[];
|
|
79
|
+
diagram?: DiagramConfig;
|
|
80
|
+
project?: ChartProjectSpec;
|
|
81
|
+
interaction?: ChartInteraction;
|
|
82
|
+
editing?: ChartEditing;
|
|
83
|
+
accessibility?: { enabled?: boolean; description?: string; [key: string]: unknown };
|
|
84
|
+
branding?: boolean | { enabled?: boolean; [key: string]: unknown };
|
|
85
|
+
theme?: ThemeMode | ThemePreset | ThemeConfig | ResolvedTheme;
|
|
86
|
+
preferences?: ChartPreferencesPatch | PreferencesStore;
|
|
87
|
+
preferencesStore?: PreferencesStore;
|
|
88
|
+
plugins?: ChartPlugin[];
|
|
89
|
+
[key: string]: unknown;
|
|
90
|
+
}
|
|
39
91
|
export interface BinTransform { type: 'bin'; field: string; output?: string; thresholds?: number; step?: number; extent?: [number, number]; }
|
|
40
92
|
export interface RadarIndicator { name: string; field: string; min?: number; max?: number; }
|
|
41
|
-
export interface DiagramNode { id: string; label
|
|
93
|
+
export interface DiagramNode { id: string; label?: string; kind?: FlowNodeKind; position?: { x: number; y: number }; size?: { width: number; height: number }; ports?: Array<{ id: string; side?: 'left' | 'right' | 'top' | 'bottom'; offset?: number }>; groupId?: string; }
|
|
42
94
|
export interface ArchitectureNode extends DiagramNode { layerId?: string; boundaryId?: string; role?: string; description?: string; }
|
|
43
95
|
export interface MindmapNode extends DiagramNode { parentId?: string; branch?: string; description?: string; }
|
|
44
96
|
export interface DiagramPoint { x: number; y: number; }
|
|
45
97
|
export interface DiagramEdge { id?: string; from: string; to: string; fromPort?: string; toPort?: string; label?: string; relation?: string; routing?: 'straight' | 'orthogonal' | 'curved'; curveTension?: number; waypoints?: DiagramPoint[]; }
|
|
98
|
+
export interface DiagramLane { id: string; label?: string; [key: string]: unknown; }
|
|
99
|
+
export interface DiagramGroup { id: string; label?: string; collapsed?: boolean; padding?: number; [key: string]: unknown; }
|
|
46
100
|
export interface DiagramElement { id?: string; type?: string; dataRef?: Record<string, unknown>; geometry?: Record<string, unknown>; [key: string]: unknown; }
|
|
47
101
|
export interface ChartEvent { chart: Chart; type?: string; target?: DiagramElement | null; datum?: Record<string, unknown>; selectedData?: Array<Record<string, unknown>>; [key: string]: unknown; }
|
|
48
102
|
export type ChartListener = (event: ChartEvent) => void;
|
|
49
103
|
export interface ChartPlugin { name?: string; install?(chart: Chart): void; beforeRender?(chart: Chart, model: Record<string, unknown>): void; afterRender?(chart: Chart, model: Record<string, unknown>): void; destroy?(chart: Chart): void; }
|
|
50
104
|
export interface ClipboardState { nodes: DiagramNode[]; edges: DiagramEdge[]; }
|
|
51
|
-
export interface
|
|
105
|
+
export interface ChartLayoutState { family: string | null; plot: Record<string, unknown>; chrome: Record<string, unknown> | null; labels: Record<string, unknown> | null; }
|
|
106
|
+
export interface ChartAxesState { y?: AxisState; right?: AxisState; [key: string]: unknown; }
|
|
107
|
+
export interface ChartBrandingState { enabled: boolean; signature: string; text: string | null; }
|
|
108
|
+
export interface ChartState { renderer: string; width: number; height: number; dataCount: number; selected: unknown[]; revision: number; history: { undo: number; redo: number }; view: Record<string, unknown> | null; style?: Partial<StyleRecommendation> & { name?: string; mode?: ThemeMode; resolvedMode?: string; palette?: ThemePalette }; layout?: ChartLayoutState | null; timeAxis?: TimeAxisState | null; axes?: ChartAxesState | null; health: ChartHealth; preferences: ChartPreferences; branding?: ChartBrandingState; warnings: Diagnostic[]; assumptions?: string[]; normalizations?: Diagnostic[]; collapsedGroups?: string[]; projectAnalytics?: ProjectAnalyticsState | null; linked?: LinkedState | null; clipboard: { nodes: number; edges: number }; [key: string]: unknown; }
|
|
52
109
|
export interface DiagramConfig { mode?: 'process' | 'architecture' | 'mindmap'; layout?: 'manual' | 'layered' | 'tree' | 'radial'; routing?: 'straight' | 'orthogonal' | 'curved'; curveTension?: number; grid?: number; snap?: boolean; }
|
|
53
110
|
export interface ArchitectureLayer { id: string; label?: string; }
|
|
54
111
|
export interface ArchitectureBoundary { id: string; label?: string; nodeIds?: string[]; padding?: number; color?: string; }
|