@taylorwong/ichartjs 2.0.19 → 2.0.21

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.
@@ -0,0 +1,50 @@
1
+ # Iteration 17 — 生产可信度与 Agent 可靠性
2
+
3
+ Iteration 17 面向生产环境强化现有 Runtime 的 Agent 使用体验。不新增图表类型,也不新增 CLI、MCP 或 HTTP 服务。
4
+
5
+ ## 17A — 契约可靠性
6
+
7
+ - 对未知顶层 Spec 配置提供诊断,不再静默忽略。
8
+ - 保持 `validateSpec()`、规范化、`createChart()`、Capabilities、TypeScript 和 Manifest 一致。
9
+ - 保留稳定诊断编码、JSON 路径、期望值和可执行建议。
10
+
11
+ ## 17B — Agent 自检
12
+
13
+ - 通过 `chart.explain()` 暴露实际生效的渲染选项和规范化结果。
14
+ - `planChart()` 遇到不支持的 Renderer 时直接报告风险,而不是只返回隐式 fallback。
15
+ - 在 `getCapabilities()` 和 `capabilities.json` 中发布 `agentReliability`。
16
+ - 保持导航、编辑和动效默认安全关闭。
17
+
18
+ ## 17C — 包与集成可信度
19
+
20
+ - 增加 npm 包 dry-run 检查,确认 Runtime、TypeScript、Skill、recipes 和能力清单均存在。
21
+ - 禁止 `.trae`、`.github`、测试和 Playground 开发文件进入发布包。
22
+ - 将 TypeScript consumer fixture 与 ESM/headless 示例纳入发布检查。
23
+
24
+ ## 17D — 渲染与无障碍验收
25
+
26
+ - 持续验证 SVG/Canvas 在标签、图表、导出和响应式布局上的一致性。
27
+ - 将浏览器验收与 headless 检查分开,并保留明确的本地服务证据。
28
+ - 在测试显式开启导航和编辑的同时,保持默认静态安全行为。
29
+
30
+ ## 验收标准
31
+
32
+ ```text
33
+ 检查数据
34
+ → 规划图表
35
+ → 校验 Spec
36
+ → 创建图表
37
+ → 检查生效配置和健康状态
38
+ → 预览或导出
39
+ ```
40
+
41
+ - 无效或被忽略的配置都可诊断。
42
+ - Agent 无需阅读 Renderer 内部代码即可确认实际渲染结果。
43
+ - npm 包只包含支持用户使用的产物。
44
+ - `npm run agent:check`、浏览器验收和 `git diff --check` 通过。
45
+
46
+ ## 不纳入本轮
47
+
48
+ - 新图表类型。
49
+ - Runtime 内置 NLP。
50
+ - CLI、MCP、HTTP、Python 或 1.x 兼容层。
@@ -12,13 +12,15 @@ Agent 应优先使用 `getCapabilities()`,不要硬编码未声明的图表类
12
12
 
13
13
  图表创建后的视觉设置使用 `getPreferenceCapabilities(chartType, { locale }) → chart.getPreferences() → validatePreferences(patch) → chart.setPreferences(patch, { source: 'agent' }) → chart.getState().preferences`。这样 Agent 与内置设置菜单始终使用同一份白名单契约。
14
14
 
15
+ 有效偏好优先级为 `defaults → chart Spec → global PreferencesStore → chart PreferencesStore`。使用 `chart.getState().preferenceResolution` 查看当前作用域、存储方式和来源。Runtime 不解析自然语言,请使用[自然语言修改工作流](conversational-workflow.md)把自然语言分流到正确的修改 API。
16
+
15
17
  Iteration 8 通过 `getChartCapability(type)` 提供逐图表能力档案,包括必需数据角色、支持的交互、Renderer、功能状态、导出和建议限制。Agent 不应猜测未声明能力。
16
18
 
17
19
  `planChart(data, { intent, renderer })` 返回版本化规划结果:主选图表、备选项、置信度、原因、缺失字段、建议编码、假设、警告、不支持请求和安全下一步。规划不会虚构业务含义、单位、日期或缺失字段。
18
20
 
19
21
  未知 intent 还会返回 `intentKnown`、`intentSuggestions` 和 `fallbackUsed`。Spec 的通道按图表类型约束:笛卡尔图表是 `x`/`y`,Pie/Funnel 是 `category`/`value`,Gauge 只使用 `value`,Heatmap 是 `x`/`y`/`color`,Radar 字段位于 `indicators`。`validateSpec()` 会拒绝不支持的通道和缺失字段。Gauge 必须显式声明 `domain`,超出范围时会报告 `VALUE_CLAMPED`;Pie 的负值报告 `NEGATIVE_VALUE_DROPPED`,没有正数占比时 `ZERO_TOTAL` 会使校验失败。
20
22
 
21
- `validateSpec()` 分开返回 `errors`、`warnings` 和 `normalizations`;诊断包含稳定代码、JSON 路径、期望值和修复建议。`chart.explain()` 返回编码、转换、交互、假设、警告、稳定记录血缘和无障碍摘要。
23
+ `validateSpec()` 分开返回 `errors`、`warnings` 和 `normalizations`;诊断包含稳定代码、JSON 路径、期望值和修复建议。未知顶层选项会返回 `UNKNOWN_SPEC_OPTION`,不会静默当作有效配置。`chart.explain()` 返回编码、转换、交互、实际生效选项、规范化结果、假设、警告、稳定记录血缘和无障碍摘要。
22
24
 
23
25
  ## 关键规则
24
26
 
@@ -8,6 +8,8 @@ iChart.js 可以分成三层:
8
8
 
9
9
  Skill 不是第二套渲染器,也不是服务端。使用 Skill 的 Agent 仍然需要 JavaScript 宿主,才能生成交互页面或文件。
10
10
 
11
+ 修改已有图表的自然语言请求请阅读[自然语言修改工作流](conversational-workflow.md)。Runtime 本身不解析自然语言;宿主 Agent 负责把自然语言映射为经过校验的 Runtime 调用。
12
+
11
13
  ## 场景选择
12
14
 
13
15
  | 需求 | 使用方式 | 运行位置 | 常见输出 |
@@ -77,6 +79,8 @@ const chart = createChart({
77
79
 
78
80
  宿主项目负责数据加载、认证、路由、持久化和状态管理。数据变化使用 `setData()`,Spec 变化使用 `update()`,替换组件前调用 `destroy()`。
79
81
 
82
+ 例如“使用深色主题、隐藏网格,并修改三月数据”应拆分为 `setPreferences()` 和 `previewEdit()`/`applyEdit()`,不要直接修改业务数据对象。提交后返回 `explain()` 和 `getState()` 的诊断结果。
83
+
80
84
  适用于管理后台、数据看板、项目管理、流程编辑和嵌入式分析页面。
81
85
 
82
86
  ## 场景二:让 Coding Agent 修改项目
@@ -116,7 +120,7 @@ npx skills add wanghetommy/ichartjs --skill ichartjs --agent codex --global --ye
116
120
  需要固定发布版本时,直接安装已发布的 Skill 目录:
117
121
 
118
122
  ```bash
119
- npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.19/skills/ichartjs \
123
+ npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.21/skills/ichartjs \
120
124
  --agent codex --global --yes
121
125
  ```
122
126
 
@@ -230,9 +230,9 @@
230
230
  "interactiveSettingsUI": true,
231
231
  "precedence": [
232
232
  "defaults",
233
+ "spec",
233
234
  "global",
234
- "chart",
235
- "temporary-agent-patch"
235
+ "chart"
236
236
  ],
237
237
  "discovery": {
238
238
  "capabilities": "getPreferenceCapabilities(chartType?, { locale? })",
@@ -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.19",
286
- "runtimeVersion": "2.0.19",
285
+ "packageVersion": "2.0.21",
286
+ "runtimeVersion": "2.0.21",
287
287
  "chartTypes": [
288
288
  "line",
289
289
  "area",
@@ -1408,5 +1408,62 @@
1408
1408
  "toggleGroupCollapse",
1409
1409
  "duplicateSelection",
1410
1410
  "pasteSelection"
1411
- ]
1411
+ ],
1412
+ "conversationalWorkflow": {
1413
+ "version": "1.0",
1414
+ "steps": [
1415
+ "read-state",
1416
+ "classify-request",
1417
+ "build-validated-change",
1418
+ "preview-or-validate",
1419
+ "confirm-when-required",
1420
+ "commit",
1421
+ "explain-and-report"
1422
+ ],
1423
+ "routing": {
1424
+ "visual": "setPreferences or setTheme",
1425
+ "replaceData": "setData",
1426
+ "businessData": "previewEdit then applyEdit",
1427
+ "spec": "update",
1428
+ "jsonPointer": "applyPatch",
1429
+ "diagram": "previewEdit then applyEdit"
1430
+ },
1431
+ "confirmation": [
1432
+ "deletion",
1433
+ "structural-change",
1434
+ "bulk-edit"
1435
+ ],
1436
+ "selfCheck": [
1437
+ "chart.explain()",
1438
+ "chart.getState()",
1439
+ "chart.getState().health"
1440
+ ]
1441
+ },
1442
+ "agentReliability": {
1443
+ "contract": {
1444
+ "validationBeforeRender": true,
1445
+ "normalizedSpecReturned": true,
1446
+ "unknownOptionsDiagnosed": true,
1447
+ "stableDiagnosticCodes": true
1448
+ },
1449
+ "selfCheck": [
1450
+ "chart.explain().effective",
1451
+ "chart.explain().normalizations",
1452
+ "chart.getState().health",
1453
+ "chart.getState().warnings"
1454
+ ],
1455
+ "integration": {
1456
+ "esm": true,
1457
+ "typescript": true,
1458
+ "headless": true,
1459
+ "svg": true,
1460
+ "canvas": true,
1461
+ "packageTarball": true
1462
+ },
1463
+ "defaults": {
1464
+ "navigation": false,
1465
+ "editing": false,
1466
+ "motion": "auto"
1467
+ }
1468
+ }
1412
1469
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taylorwong/ichartjs",
3
- "version": "2.0.19",
3
+ "version": "2.0.21",
4
4
  "description": "Agent-first, renderer-independent charting and project visualization runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -43,9 +43,10 @@
43
43
  "deps:check": "node scripts/check-cycles.mjs",
44
44
  "docs:examples": "node examples/agent-workflow.mjs",
45
45
  "docs:check": "node scripts/check-agent-docs.mjs",
46
+ "package:check": "node scripts/check-package.mjs",
46
47
  "example:agent": "node examples/agent-workflow.mjs",
47
48
  "playground": "node scripts/serve-playground.mjs",
48
- "agent:check": "npm run contracts:check && npm run types:check && npm run deps:check && npm run docs:check && npm run docs:examples && npm run check && npm test",
49
+ "agent:check": "npm run contracts:check && npm run types:check && npm run deps:check && npm run docs:check && npm run package:check && npm run docs:examples && npm run check && npm test",
49
50
  "rc:check": "npm test && npm run check && git diff --check"
50
51
  },
51
52
  "devDependencies": {
@@ -8,6 +8,7 @@ description: Plan, validate, render, explain, and safely edit iChart.js visualiz
8
8
  Use the public Agent contract as the source of truth. Do not infer capabilities from renderer internals or duplicate chart-selection logic in generated code.
9
9
 
10
10
  Read the [usage scenarios](https://github.com/wanghetommy/ichartjs/blob/master/docs/agent/usage-scenarios.md) when the request is ambiguous about whether the output should be a live project component, a Coding Agent change, a Skill-generated artifact, or a scheduled report.
11
+ Read the [conversational workflow](https://github.com/wanghetommy/ichartjs/blob/master/docs/agent/conversational-workflow.md) when the user asks to change an existing chart in natural language.
11
12
 
12
13
  ## Source and Runtime Setup
13
14
 
@@ -21,7 +22,7 @@ Recommended installation:
21
22
  npx skills add wanghetommy/ichartjs --skill ichartjs
22
23
  ```
23
24
 
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
+ Use `--agent codex --global --yes` for global non-interactive Codex installation. Use the tagged directory `https://github.com/wanghetommy/ichartjs/tree/v2.0.21/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
26
 
26
27
  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
28
 
@@ -48,6 +49,7 @@ Do not install the unscoped npm registry package named `ichartjs`; it is current
48
49
  13. Provide an exact preview URL or artifact path and report assumptions, warnings, and deferred checks.
49
50
  14. Prefer `theme: { mode: 'auto', preset, palette }`; preserve explicit user style choices and use `chart.setTheme()` for live switching.
50
51
  15. For post-creation visual changes, call `getPreferenceCapabilities(chartType, { locale })`, validate the patch with `validatePreferences()`, apply it with `chart.setPreferences(..., { source: 'agent' })`, and verify `chart.getState().preferences`.
52
+ 16. For natural-language changes, classify the request before mutating: visual → `setPreferences()`/`setTheme()`, complete data replacement → `setData()`, business or Diagram edit → `previewEdit()`/`applyEdit()`, normal Spec change → `update()`, exact JSON path → `applyPatch()`. Verify `chart.getState().preferenceResolution` and `chart.explain()` after commit.
51
53
 
52
54
  Use `@taylorwong/ichartjs` for package imports. Use `examples/agent-workflow.mjs` as the executable baseline when working in the repository.
53
55
 
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { inspectData } from './data.mjs';
5
5
  import { planStyle, styleCapabilities } from './theme.mjs';
6
- import { defaultPreferences, preferenceDensities, preferenceMotions, preferenceTriStates } from './preferences.mjs';
6
+ import { defaultPreferences, preferenceDensities, preferenceMotions, preferencePrecedence, preferenceTriStates } from './preferences.mjs';
7
7
  import { businessModels, chartProfiles, chartTypes, commandTypes, contractVersion, diagramEdgeModels, diagramOperations, exportTypes, flowNodeKinds, interactionDefaults, renderers } from './contract-registry.mjs';
8
8
 
9
9
  export { chartProfiles, chartTypes };
@@ -81,7 +81,7 @@ export function getPreferenceCapabilities(chartType = null, { locale = 'en' } =
81
81
  field({ path: 'branding.enabled', group: 'components', key: 'branding', type: 'tri-state', values: preferenceTriStates, defaultValue: defaultPreferences.branding.enabled }),
82
82
  field({ path: 'motion', group: 'behavior', key: 'motion', type: 'enum', values: preferenceMotions, defaultValue: defaultPreferences.motion })
83
83
  ];
84
- return { version: '1.0', locale: resolvedLocale, chartType: profile?.type || null, scopes, persistence: ['memory', 'localStorage', 'adapter'], precedence: ['defaults', 'global', 'chart', 'temporary-agent-patch'], fields };
84
+ return { version: '1.0', locale: resolvedLocale, chartType: profile?.type || null, scopes, persistence: ['memory', 'localStorage', 'adapter'], precedence: [...preferencePrecedence], fields };
85
85
  }
86
86
 
87
87
  export function planChart(input, options = {}) {
@@ -121,6 +121,11 @@ export function planChart(input, options = {}) {
121
121
  const confidence = !report.rows || requiredFields.length ? 0.35 : warnings.some(item => item.code !== 'MISSING_VALUE') ? 0.65 : 0.9;
122
122
  const styleRecommendation = planStyle({ type: primary, intent: requestedIntent, data: Array.isArray(input) ? input : input?.values || input, encoding: { x: report.dimensions[0] ? { field: report.dimensions[0] } : undefined, y: report.measures.map(field => ({ field })) } }, options);
123
123
  warnings.push(...styleRecommendation.warnings);
124
+ const unsupportedRequests = options.renderer && options.renderer !== 'auto' && !profile.renderers.includes(options.renderer) ? [`renderer:${options.renderer}`] : [];
125
+ if (unsupportedRequests.length) {
126
+ warnings.push(warning('UNSUPPORTED_RENDERER', 'renderer', `${primary} does not support renderer ${options.renderer}.`, `Use one of: ${profile.renderers.join(', ')}.`));
127
+ nextActions.push(`Change renderer to ${profile.renderers[0]}.`);
128
+ }
124
129
  return {
125
130
  version: '1.0',
126
131
  intent: requestedIntent,
@@ -135,7 +140,7 @@ export function planChart(input, options = {}) {
135
140
  suggestedEncodings: { dimension: report.dimensions[0] || null, measure: report.measures[0] || null, secondaryMeasure: report.measures[1] || null },
136
141
  assumptions: ['Field roles are inferred from provided values; business meaning and units are not inferred.'],
137
142
  warnings,
138
- unsupportedRequests: options.renderer && !profile.renderers.includes(options.renderer) ? [`renderer:${options.renderer}`] : [],
143
+ unsupportedRequests,
139
144
  nextActions,
140
145
  capability: getChartCapability(primary),
141
146
  styleRecommendation,
@@ -157,7 +162,9 @@ export function explainChart(spec, model = {}) {
157
162
  encodings,
158
163
  transforms: spec.transform ? (Array.isArray(spec.transform) ? spec.transform : [spec.transform]).map(item => item.type) : [],
159
164
  interactions: Object.keys(spec.interaction || {}).filter(key => spec.interaction[key]),
165
+ effective: { renderer: spec.renderer, title: spec.title, legend: spec.legend, grid: spec.grid, labels: spec.labels, interaction: spec.interaction, branding: spec.branding },
160
166
  assumptions: [...(model.data?.assumptions || []), ...(model.state?.projectAnalytics?.assumptions || [])],
167
+ normalizations: model.normalizations || [],
161
168
  warnings,
162
169
  axes: model.state?.axes || null,
163
170
  style: spec.theme && typeof spec.theme === 'object' ? { name: spec.theme.name, preset: spec.theme.preset, mode: spec.theme.mode, resolvedMode: spec.theme.resolvedMode, palette: spec.theme.palette, reasons: spec.theme.reasons || [], warnings: spec.theme.warnings || [] } : planStyle(spec),
@@ -223,6 +230,26 @@ export function getCapabilities() {
223
230
  data: ['normalize', 'inspect', 'filter', 'sort', 'groupBy', 'sum', 'average', 'topN', 'percentage', 'bin'],
224
231
  themes: [...styleCapabilities.modes],
225
232
  styleSystem: JSON.parse(JSON.stringify(styleCapabilities)),
233
+ conversationalWorkflow: {
234
+ version: '1.0',
235
+ steps: ['read-state', 'classify-request', 'build-validated-change', 'preview-or-validate', 'confirm-when-required', 'commit', 'explain-and-report'],
236
+ routing: {
237
+ visual: 'setPreferences or setTheme',
238
+ replaceData: 'setData',
239
+ businessData: 'previewEdit then applyEdit',
240
+ spec: 'update',
241
+ jsonPointer: 'applyPatch',
242
+ diagram: 'previewEdit then applyEdit'
243
+ },
244
+ confirmation: ['deletion', 'structural-change', 'bulk-edit'],
245
+ selfCheck: ['chart.explain()', 'chart.getState()', 'chart.getState().health']
246
+ },
247
+ agentReliability: {
248
+ contract: { validationBeforeRender: true, normalizedSpecReturned: true, unknownOptionsDiagnosed: true, stableDiagnosticCodes: true },
249
+ selfCheck: ['chart.explain().effective', 'chart.explain().normalizations', 'chart.getState().health', 'chart.getState().warnings'],
250
+ integration: { esm: true, typescript: true, headless: true, svg: true, canvas: true, packageTarball: true },
251
+ defaults: { navigation: false, editing: false, motion: 'auto' }
252
+ },
226
253
  preferences: {
227
254
  version: '1.0',
228
255
  scopes: preferenceCapabilities.scopes,
@@ -231,6 +258,13 @@ export function getCapabilities() {
231
258
  agentAdjustable: true,
232
259
  interactiveSettingsUI: true,
233
260
  precedence: preferenceCapabilities.precedence,
261
+ discovery: {
262
+ capabilities: 'getPreferenceCapabilities(chartType?, { locale? })',
263
+ current: 'chart.getPreferences()',
264
+ validate: 'validatePreferences(patch, { partial: true })',
265
+ apply: "chart.setPreferences(patch, { scope, source: 'agent' })",
266
+ verify: 'chart.getState().preferences'
267
+ },
234
268
  schema: preferenceCapabilities
235
269
  },
236
270
  commands: [...commandTypes],
package/src/index.mjs CHANGED
@@ -20,7 +20,7 @@ import { diagramLayoutModes, diagramModes, edgeRoutingModes, normalizeDiagramSpe
20
20
  import { normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries } from './project-analytics.mjs';
21
21
  import { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId } from './project-linking.mjs';
22
22
  import { explainChart, getCapabilities as discoverCapabilities, getChartCapability, getPreferenceCapabilities, planChart } from './capabilities.mjs';
23
- import { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, normalizePreferences, validatePreferences } from './preferences.mjs';
23
+ import { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, normalizePreferences, preferencePrecedence, validatePreferences } from './preferences.mjs';
24
24
  import { mountChartSettings } from './preferences-ui.mjs';
25
25
  import { ChartValidationError } from './errors.mjs';
26
26
  import { diagramOperations, flowNodeKinds } from './contract-registry.mjs';
@@ -91,6 +91,8 @@ function sceneToSvgString(scene, spec = {}) {
91
91
  attrs.push(`x1="${g.x1}" y1="${g.y1}" x2="${g.x2}" y2="${g.y2}"`);
92
92
  } else if (node.type === 'circle') {
93
93
  attrs.push(`cx="${g.cx}" cy="${g.cy}" r="${g.r}"`);
94
+ } else if (node.type === 'ellipse') {
95
+ attrs.push(`cx="${g.cx}" cy="${g.cy}" rx="${g.rx}" ry="${g.ry}"`);
94
96
  } else if (node.type === 'arc') {
95
97
  tag = 'path';
96
98
  const large = g.end - g.start > Math.PI ? 1 : 0;
@@ -129,7 +131,7 @@ function sceneToSvgString(scene, spec = {}) {
129
131
  } else {
130
132
  tag = 'g';
131
133
  }
132
- if (['path', 'circle', 'rect', 'arc'].includes(node.type)) attrs.push(`fill="${hasPaint(s, 'fill') ? _svgEscape(s.fill, 'attr') : 'none'}"`);
134
+ if (['path', 'circle', 'ellipse', 'rect', 'arc'].includes(node.type)) attrs.push(`fill="${hasPaint(s, 'fill') ? _svgEscape(s.fill, 'attr') : 'none'}"`);
133
135
  else if (node.type === 'text') attrs.push(`fill="${hasPaint(s, 'fill') ? _svgEscape(s.fill, 'attr') : '#0f172a'}"`);
134
136
  if (hasPaint(s, 'stroke')) attrs.push(`stroke="${_svgEscape(s.stroke, 'attr')}"`);
135
137
  else if (s.stroke != null) attrs.push('stroke="none"');
@@ -197,6 +199,7 @@ export class Chart {
197
199
  this.chartId = input.chartId ?? input.id ?? null;
198
200
  this._preferencesStore = isPreferencesStore(input.preferencesStore) ? input.preferencesStore : isPreferencesStore(input.preferences) ? input.preferences : null;
199
201
  this._localPreferences = this._preferencesStore ? null : normalizePreferences(input.preferences || {});
202
+ this._localPreferenceSource = input.preferences ? 'host' : null;
200
203
  delete result.spec.preferences;
201
204
  delete result.spec.preferencesStore;
202
205
  delete result.spec.chartId;
@@ -442,8 +445,9 @@ export class Chart {
442
445
  }
443
446
  getTheme() { return clone(this.spec.theme); }
444
447
  getPreferences() { return this._preferencesStore ? this._preferencesStore.getEffective(this.chartId) : clone(this._localPreferences || defaultPreferences); }
445
- setPreferences(patch = {}, options = {}) { const checked = validatePreferences(patch, { partial: true }); if (!checked.valid) throw new ChartValidationError('setPreferences', checked.errors); if (this._preferencesStore) { const scope = options.scope === 'global' ? 'global' : 'chart'; if (scope === 'global') this._preferencesStore.setGlobal(checked.value, { ...options, source: options.source || 'user' }); else this._preferencesStore.setChart(this.chartId || 'default', checked.value, { ...options, source: options.source || 'user' }); return this; } const snapshot = this._snapshotMutation(); try { this._localPreferences = mergePreferences(this._localPreferences, checked.value); this._preferencesSnapshot = JSON.stringify(this._localPreferences); this._resolveStyle(); this._commitMutation(); this.emit('preferenceschange', { chart: this, preferences: this.getPreferences(), source: options.source || 'user', scope: 'chart', persisted: false }); return this; } catch (error) { this._restoreMutation(snapshot); throw error; } }
446
- resetPreferences(options = {}) { if (this._preferencesStore) { this._preferencesStore.reset({ ...options, scope: options.scope || 'chart', chartId: this.chartId || 'default' }); return this; } const snapshot = this._snapshotMutation(); try { this._localPreferences = normalizePreferences({}); this._preferencesSnapshot = JSON.stringify(this._localPreferences); this._resolveStyle(); this._commitMutation(); this.emit('preferenceschange', { chart: this, preferences: this.getPreferences(), source: options.source || 'user', scope: 'chart', persisted: false }); return this; } catch (error) { this._restoreMutation(snapshot); throw error; } }
448
+ getPreferenceResolution() { if (this._preferencesStore?.getResolution) return this._preferencesStore.getResolution(this.chartId); return { version: '1.0', precedence: [...preferencePrecedence], storage: this._preferencesStore?.storage || 'memory', scopes: { defaults: 'runtime', spec: 'chart-spec', global: this._preferencesStore ? 'host' : null, chart: this._preferencesStore ? 'host' : this._localPreferenceSource } }; }
449
+ setPreferences(patch = {}, options = {}) { const checked = validatePreferences(patch, { partial: true }); if (!checked.valid) throw new ChartValidationError('setPreferences', checked.errors); const source = options.source || 'user'; if (this._preferencesStore) { const scope = options.scope === 'global' ? 'global' : 'chart'; if (scope === 'global') this._preferencesStore.setGlobal(checked.value, { ...options, source }); else this._preferencesStore.setChart(this.chartId || 'default', checked.value, { ...options, source }); return this; } const snapshot = this._snapshotMutation(); try { this._localPreferences = mergePreferences(this._localPreferences, checked.value); this._localPreferenceSource = source; this._preferencesSnapshot = JSON.stringify(this._localPreferences); this._resolveStyle(); this._commitMutation(); this.emit('preferenceschange', { chart: this, preferences: this.getPreferences(), source, scope: 'chart', persisted: false }); return this; } catch (error) { this._restoreMutation(snapshot); throw error; } }
450
+ resetPreferences(options = {}) { if (this._preferencesStore) { this._preferencesStore.reset({ ...options, scope: options.scope || 'chart', chartId: this.chartId || 'default' }); return this; } const snapshot = this._snapshotMutation(); try { this._localPreferences = normalizePreferences({}); this._localPreferenceSource = options.source || 'user'; this._preferencesSnapshot = JSON.stringify(this._localPreferences); this._resolveStyle(); this._commitMutation(); this.emit('preferenceschange', { chart: this, preferences: this.getPreferences(), source: options.source || 'user', scope: 'chart', persisted: false }); return this; } catch (error) { this._restoreMutation(snapshot); throw error; } }
447
451
  resize(width = this.spec.width, height = this.spec.height) { this.spec.width = width; this.spec.height = height; this.render(); this.emit('resize', { chart: this, width, height }); return this; }
448
452
  getSpec() { return JSON.parse(JSON.stringify(this.spec)); }
449
453
  _getDiagnostics() { return [...new Map([...(this._specDiagnostics?.warnings || []), ...(this.model.data?.warnings || []), ...(this.spec.theme?.warnings || [])].map(item => [`${item.code}:${item.path || ''}`, item])).values()]; }
@@ -455,13 +459,13 @@ export class Chart {
455
459
  const clampedValues = warnings.filter(item => item.code === 'VALUE_CLAMPED').reduce((sum, item) => sum + Number(item.count || 1), 0);
456
460
  return { version: '1.0', status: isEmpty ? 'empty' : warnings.length ? 'degraded' : 'ready', renderable: !isEmpty, issues: [...new Set(warnings.map(item => item.code))], metrics: { warnings: warnings.length, suppressedLabels, clampedValues, renderedMarks: marks.length } };
457
461
  }
458
- getState() { const brandingSignature = discoverCapabilities().branding.signature, warnings = this._getDiagnostics(), layoutState = this.model.state?.plot ? { family: this.model.state.layoutFamily || null, plot: clone(this.model.state.plot), chrome: clone(this.model.state.chrome || null), labels: clone(this.model.state.labelLayout || null) } : null; return { renderer: this.renderer.constructor.name, width: this.spec.width, height: this.spec.height, dataCount: this.model.data.rows.length, selected: [...this._selected.values()], revision: this._revision, history: this._history.state(), view: clone(this.spec.view || null), style: clone({ name: this.spec.theme.name, mode: this.spec.theme.mode, resolvedMode: this.spec.theme.resolvedMode, preset: this.spec.theme.preset, palette: this.spec.theme.palette, reasons: this.spec.theme.reasons }), layout: layoutState, timeAxis: clone(this.model.state?.timeAxis || null), axes: clone(this.model.state?.axes || null), health: this._getHealth(warnings), preferences: this.getPreferences(), branding: { enabled: Boolean(this.spec.branding?.enabled), signature: brandingSignature, text: this.spec.branding?.enabled === true ? brandingSignature : null }, warnings: clone(warnings), assumptions: clone(this.model.data?.assumptions || []), normalizations: clone(this._specDiagnostics?.normalizations || []), collapsedGroups: this.getCollapsedGroupIds(), clipboard: { nodes: this._clipboard?.nodes?.length || 0, edges: this._clipboard?.edges?.length || 0 }, projectAnalytics: clone(this.model.state?.projectAnalytics || null), linked: clone(this.model.state?.linked || null) }; }
462
+ getState() { const brandingSignature = discoverCapabilities().branding.signature, warnings = this._getDiagnostics(), layoutState = this.model.state?.plot ? { family: this.model.state.layoutFamily || null, plot: clone(this.model.state.plot), chrome: clone(this.model.state.chrome || null), labels: clone(this.model.state.labelLayout || null) } : null; return { renderer: this.renderer.constructor.name, width: this.spec.width, height: this.spec.height, dataCount: this.model.data.rows.length, selected: [...this._selected.values()], revision: this._revision, history: this._history.state(), view: clone(this.spec.view || null), style: clone({ name: this.spec.theme.name, mode: this.spec.theme.mode, resolvedMode: this.spec.theme.resolvedMode, preset: this.spec.theme.preset, palette: this.spec.theme.palette, reasons: this.spec.theme.reasons }), layout: layoutState, timeAxis: clone(this.model.state?.timeAxis || null), axes: clone(this.model.state?.axes || null), health: this._getHealth(warnings), preferences: this.getPreferences(), preferenceResolution: this.getPreferenceResolution(), branding: { enabled: Boolean(this.spec.branding?.enabled), signature: brandingSignature, text: this.spec.branding?.enabled === true ? brandingSignature : null }, warnings: clone(warnings), assumptions: clone(this.model.data?.assumptions || []), normalizations: clone(this._specDiagnostics?.normalizations || []), collapsedGroups: this.getCollapsedGroupIds(), clipboard: { nodes: this._clipboard?.nodes?.length || 0, edges: this._clipboard?.edges?.length || 0 }, projectAnalytics: clone(this.model.state?.projectAnalytics || null), linked: clone(this.model.state?.linked || null) }; }
459
463
  getProjectAnalytics() { return clone(this.model.state?.projectAnalytics || null); }
460
464
  getLinkedState() { return clone(this.model.state?.linked || null); }
461
465
  setLinkedFilters(filters = {}) { this.spec.project = { ...(this.spec.project || {}), linked: { ...(this.spec.project?.linked || {}), filters: normalizeLinkedFilters(filters) } }; this.emit('linkedstatechange', { chart: this, linked: this.spec.project.linked }); return this.render(); }
462
466
  setLinkedSelection(selection = []) { this.spec.project = { ...(this.spec.project || {}), linked: { ...(this.spec.project?.linked || {}), selection: normalizeLinkedSelection(selection) } }; this.emit('linkedstatechange', { chart: this, linked: this.spec.project.linked }); return this.render(); }
463
467
  describe() { return { type: this.spec.type, renderer: this.renderer.constructor.name, dimensions: [this.spec.encoding.x?.field || this.spec.encoding.category?.field], measures: (Array.isArray(this.spec.encoding.y) ? this.spec.encoding.y : [this.spec.encoding.y || this.spec.encoding.value]).filter(Boolean).map(encoding => encoding.field), dataCount: this.model.data.rows.length, theme: this.spec.theme?.name || 'custom', interactions: Object.keys(this.spec.interaction || {}).filter(key => this.spec.interaction[key]) }; }
464
- explain() { const explanation = explainChart(this.spec, this.model), state = this.getState(); return { ...explanation, layout: state.layout, timeAxis: state.timeAxis, warnings: state.warnings, health: state.health }; }
468
+ explain() { const explanation = explainChart(this.spec, { ...this.model, normalizations: this._specDiagnostics?.normalizations || [] }), state = this.getState(); return { ...explanation, layout: state.layout, timeAxis: state.timeAxis, warnings: state.warnings, health: state.health }; }
465
469
  getAccessibleDescription() { const description = this.spec.accessibility?.description || this.spec.title?.text || `${this.spec.type} chart`; return `${description}; ${this.model.data.rows.length} data items.`; }
466
470
  inspectDataSchema() { return inspectDataSchema(this.spec.data.schema || this.spec.schema); }
467
471
  validateData() { return validateData(this.toDataTable(), this.spec.data.schema || this.spec.schema, this.spec.validationOptions); }
@@ -771,4 +775,4 @@ export { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, cr
771
775
 
772
776
  export { contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin };
773
777
  export { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, mountChartSettings, normalizePreferences, validatePreferences };
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 };
778
+ export const iChart = { version: '2.0.21', 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 };
@@ -8,6 +8,7 @@ export const preferenceVersion = '1.0';
8
8
  export const preferenceDensities = ['compact', 'comfortable', 'spacious'];
9
9
  export const preferenceTriStates = ['auto', true, false];
10
10
  export const preferenceMotions = ['auto', 'full', 'reduced', 'off'];
11
+ export const preferencePrecedence = ['defaults', 'spec', 'global', 'chart'];
11
12
 
12
13
  export const defaultPreferences = Object.freeze({
13
14
  version: preferenceVersion,
@@ -101,6 +102,7 @@ function readState(storage, key) {
101
102
 
102
103
  export function createPreferencesStore(options = {}) {
103
104
  const storage = resolveStorage(options.storage);
105
+ const storageKind = options.storage === 'localStorage' ? 'localStorage' : storage ? 'adapter' : 'memory';
104
106
  const storageKey = options.storageKey || 'ichartjs:preferences:v1';
105
107
  const loaded = readState(storage, storageKey);
106
108
  let state = loaded || {
@@ -108,6 +110,10 @@ export function createPreferencesStore(options = {}) {
108
110
  global: normalizePreferences(options.global || {}),
109
111
  charts: Object.fromEntries(Object.entries(options.charts || {}).map(([id, value]) => [id, normalizePreferences(value, { partial: true })]))
110
112
  };
113
+ const sources = {
114
+ global: loaded ? 'persisted' : options.global ? 'host' : null,
115
+ charts: loaded ? Object.fromEntries(Object.keys(loaded.charts || {}).map(id => [id, 'persisted'])) : Object.fromEntries(Object.keys(options.charts || {}).map(id => [id, 'host']))
116
+ };
111
117
  const listeners = new Set();
112
118
  const persist = () => {
113
119
  if (!storage) return false;
@@ -121,6 +127,17 @@ export function createPreferencesStore(options = {}) {
121
127
  const getGlobal = () => clone(state.global);
122
128
  const getChart = chartId => clone(state.charts[String(chartId)] || {});
123
129
  const getEffective = chartId => mergePreferences(defaultPreferences, state.global, chartId == null ? {} : state.charts[String(chartId)] || {});
130
+ const getResolution = chartId => ({
131
+ version: '1.0',
132
+ precedence: [...preferencePrecedence],
133
+ storage: storageKind,
134
+ scopes: {
135
+ defaults: 'runtime',
136
+ spec: 'chart-spec',
137
+ global: sources.global,
138
+ chart: chartId == null ? null : sources.charts[String(chartId)] || null
139
+ }
140
+ });
124
141
  const update = (scope, chartId, patch, { source = 'user', persist: shouldPersist = true } = {}) => {
125
142
  const checked = validatePreferences(patch, { partial: true });
126
143
  if (!checked.valid) { const error = new Error(checked.errors.map(item => item.message).join(' ')); error.code = 'INVALID_PREFERENCES'; error.details = checked.errors; throw error; }
@@ -128,7 +145,9 @@ export function createPreferencesStore(options = {}) {
128
145
  else {
129
146
  const key = String(chartId || 'default');
130
147
  state.charts[key] = merge(state.charts[key] || {}, checked.value);
148
+ sources.charts[key] = source;
131
149
  }
150
+ if (scope === 'global') sources.global = source;
132
151
  const persisted = shouldPersist ? persist() : false;
133
152
  notify(scope, chartId, source, persisted);
134
153
  return scope === 'global' ? getGlobal() : getEffective(chartId);
@@ -140,19 +159,23 @@ export function createPreferencesStore(options = {}) {
140
159
  getGlobal,
141
160
  getChart,
142
161
  getEffective,
162
+ getResolution,
143
163
  setGlobal(patch, options = {}) { return update('global', null, patch, options); },
144
164
  setChart(chartId, patch, options = {}) { return update('chart', chartId, patch, options); },
145
165
  reset({ scope = 'all', chartId, persist: shouldPersist = true, source = 'user' } = {}) {
146
166
  if (scope === 'all' || scope === 'global') state.global = normalizePreferences({});
147
167
  if (scope === 'all') state.charts = {};
148
168
  if (scope === 'chart' && chartId != null) delete state.charts[String(chartId)];
169
+ if (scope === 'all' || scope === 'global') sources.global = source;
170
+ if (scope === 'all') sources.charts = {};
171
+ if (scope === 'chart' && chartId != null) sources.charts[String(chartId)] = source;
149
172
  const persisted = shouldPersist ? persist() : false;
150
173
  notify(scope, chartId, source, persisted);
151
174
  return chartId != null ? getEffective(chartId) : getGlobal();
152
175
  },
153
176
  subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
154
177
  persist,
155
- storage: storage ? 'localStorage' : 'memory'
178
+ storage: storageKind
156
179
  };
157
180
  }
158
181
 
package/src/project.mjs CHANGED
@@ -68,7 +68,7 @@ function diagramNodeLabelLayout(row, kind, geometry, spec, font = null) {
68
68
 
69
69
  function flowNodeVisual(kind, box) {
70
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 } };
71
+ if (kind === 'start' || kind === 'end') return { type: 'ellipse', geometry: { cx, cy, rx: box.width / 2, ry: box.height / 2 } };
72
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
73
  if (kind === 'io') {
74
74
  const skew = Math.min(18, box.width * 0.18);
@@ -557,7 +557,7 @@ export function buildProjectScene(spec) {
557
557
  const geometry = node.geometry;
558
558
  ['x', 'x1', 'x2', 'cx'].forEach(key => { if (geometry[key] != null) geometry[key] = mapX(geometry[key]); });
559
559
  ['y', 'y1', 'y2', 'cy'].forEach(key => { if (geometry[key] != null) geometry[key] = mapY(geometry[key]); });
560
- ['width', 'height', 'r'].forEach(key => { if (geometry[key] != null) geometry[key] *= view.scale; });
560
+ ['width', 'height', 'r', 'rx', 'ry'].forEach(key => { if (geometry[key] != null) geometry[key] *= view.scale; });
561
561
  if (geometry.points) geometry.points = geometry.points.map(point => ({ x: mapX(point.x), y: mapY(point.y) }));
562
562
  if (node.bounds) node.bounds = { x: mapX(node.bounds.x), y: mapY(node.bounds.y), width: node.bounds.width * view.scale, height: node.bounds.height * view.scale };
563
563
  });
package/src/renderer.mjs CHANGED
@@ -31,7 +31,7 @@ export class CanvasRenderer extends BaseRenderer {
31
31
  mount(container) { super.mount(container); this.canvas = this.container.tagName === 'CANVAS' ? this.container : document.createElement('canvas'); if (this.canvas !== this.container) { this.container.innerHTML = ''; this.container.appendChild(this.canvas); } this.canvas.style.background = this.options.background || '#ffffff'; this.ctx = this.canvas.getContext('2d'); return this; }
32
32
  resize(width, height, pixelRatio = globalThis.devicePixelRatio || 1) { this.width = width; this.height = height; this.pixelRatio = pixelRatio; this.canvas.width = Math.round(width * pixelRatio); this.canvas.height = Math.round(height * pixelRatio); if (this.canvas.style) { this.canvas.style.width = `${width}px`; this.canvas.style.height = `${height}px`; } this.ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); }
33
33
  clear() { if (this.ctx) this.ctx.clearRect(0, 0, this.width, this.height); }
34
- render(scene) { this.clear(); const ctx = this.ctx; if (hasFill({ fill: this.options?.background })) { ctx.save(); ctx.fillStyle = this.options.background; ctx.fillRect(0, 0, this.width, this.height); ctx.restore(); } const draw = node => { if (!node.visible) return; const g = node.geometry || {}; const s = node.style || {}; ctx.save(); ctx.globalAlpha = s.opacity == null ? 1 : s.opacity; ctx.fillStyle = hasFill(s) ? color(s, 'fill', node.highlighted || node.selected ? '#1d4ed8' : '#2563eb') : 'rgba(0,0,0,0)'; ctx.strokeStyle = hasStroke(s) ? color(s, 'stroke', '#0f172a') : 'rgba(0,0,0,0)'; ctx.lineWidth = s.strokeWidth || 1; if (node.type === 'rect') { if (hasFill(s)) ctx.fillRect(g.x, g.y, g.width, g.height); if (hasStroke(s)) ctx.strokeRect(g.x, g.y, g.width, g.height); } else if (node.type === 'line') { ctx.beginPath(); ctx.moveTo(g.x1, g.y1); ctx.lineTo(g.x2, g.y2); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'path') { ctx.beginPath(); if (g.curve === 'cubic' && g.points?.length === 4) { ctx.moveTo(g.points[0].x, g.points[0].y); ctx.bezierCurveTo(g.points[1].x, g.points[1].y, g.points[2].x, g.points[2].y, g.points[3].x, g.points[3].y); } else g.points.forEach((point, index) => index ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y)); if (g.closed) ctx.closePath(); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'circle') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, 0, Math.PI * 2); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'arc') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, g.start, g.end); if (g.innerR > 0) { ctx.arc(g.cx, g.cy, g.innerR, g.end, g.start, true); } else ctx.lineTo(g.cx, g.cy); ctx.closePath(); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'text') { ctx.fillStyle = hasFill(s) ? s.fill : '#0f172a'; ctx.font = s.font || '12px system-ui'; const align = s.textAlign || (s.textAnchor === 'end' ? 'end' : s.textAnchor === 'middle' || s.textAnchor === 'center' ? 'center' : s.textAnchor === 'start' ? 'start' : 'left'); ctx.textAlign = align; ctx.textBaseline = s.textBaseline || s.baseline || 'alphabetic'; if (Number.isFinite(Number(s.rotation)) && Number(s.rotation) !== 0) { ctx.translate(g.x, g.y); ctx.rotate(Number(s.rotation) * Math.PI / 180); ctx.fillText(g.text, 0, 0); } else ctx.fillText(g.text, g.x, g.y); } node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(draw); ctx.restore(); }; draw(scene.root); }
34
+ render(scene) { this.clear(); const ctx = this.ctx; if (hasFill({ fill: this.options?.background })) { ctx.save(); ctx.fillStyle = this.options.background; ctx.fillRect(0, 0, this.width, this.height); ctx.restore(); } const draw = node => { if (!node.visible) return; const g = node.geometry || {}; const s = node.style || {}; ctx.save(); ctx.globalAlpha = s.opacity == null ? 1 : s.opacity; ctx.fillStyle = hasFill(s) ? color(s, 'fill', node.highlighted || node.selected ? '#1d4ed8' : '#2563eb') : 'rgba(0,0,0,0)'; ctx.strokeStyle = hasStroke(s) ? color(s, 'stroke', '#0f172a') : 'rgba(0,0,0,0)'; ctx.lineWidth = s.strokeWidth || 1; if (node.type === 'rect') { if (hasFill(s)) ctx.fillRect(g.x, g.y, g.width, g.height); if (hasStroke(s)) ctx.strokeRect(g.x, g.y, g.width, g.height); } else if (node.type === 'line') { ctx.beginPath(); ctx.moveTo(g.x1, g.y1); ctx.lineTo(g.x2, g.y2); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'path') { ctx.beginPath(); if (g.curve === 'cubic' && g.points?.length === 4) { ctx.moveTo(g.points[0].x, g.points[0].y); ctx.bezierCurveTo(g.points[1].x, g.points[1].y, g.points[2].x, g.points[2].y, g.points[3].x, g.points[3].y); } else g.points.forEach((point, index) => index ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y)); if (g.closed) ctx.closePath(); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'circle') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, 0, Math.PI * 2); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'ellipse') { ctx.beginPath(); if (typeof ctx.ellipse === 'function') ctx.ellipse(g.cx, g.cy, g.rx, g.ry, 0, 0, Math.PI * 2); else { ctx.save(); ctx.translate(g.cx, g.cy); ctx.scale(g.rx, g.ry); ctx.arc(0, 0, 1, 0, Math.PI * 2); ctx.restore(); } if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'arc') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, g.start, g.end); if (g.innerR > 0) { ctx.arc(g.cx, g.cy, g.innerR, g.end, g.start, true); } else ctx.lineTo(g.cx, g.cy); ctx.closePath(); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'text') { ctx.fillStyle = hasFill(s) ? s.fill : '#0f172a'; ctx.font = s.font || '12px system-ui'; const align = s.textAlign || (s.textAnchor === 'end' ? 'end' : s.textAnchor === 'middle' || s.textAnchor === 'center' ? 'center' : s.textAnchor === 'start' ? 'start' : 'left'); ctx.textAlign = align; ctx.textBaseline = s.textBaseline || s.baseline || 'alphabetic'; if (Number.isFinite(Number(s.rotation)) && Number(s.rotation) !== 0) { ctx.translate(g.x, g.y); ctx.rotate(Number(s.rotation) * Math.PI / 180); ctx.fillText(g.text, 0, 0); } else ctx.fillText(g.text, g.x, g.y); } node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(draw); ctx.restore(); }; draw(scene.root); }
35
35
  exportImage(type = 'image/png') { return this.canvas.toDataURL(type); }
36
36
  }
37
37
 
@@ -86,6 +86,6 @@ export class SVGRenderer extends BaseRenderer {
86
86
  this.svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
87
87
  this.width = w; this.height = h;
88
88
  }
89
- this.clear(); const renderNode = node => { if (!node.visible || node.type === 'root') { node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); return; } const g = node.geometry || {}, s = node.style || {}; const element = document.createElementNS('http://www.w3.org/2000/svg', node.type === 'path' || node.type === 'arc' ? 'path' : node.type === 'text' ? 'text' : node.type); element.setAttribute('id', node.id); element.setAttribute('data-node-id', node.id); if (node.dataRef) element.dataset.dataRef = JSON.stringify(node.dataRef); if (node.type === 'rect') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); element.setAttribute('width', g.width); element.setAttribute('height', g.height); } else if (node.type === 'line') { ['x1', 'y1', 'x2', 'y2'].forEach(key => element.setAttribute(key, g[key])); } else if (node.type === 'circle') { element.setAttribute('cx', g.cx); element.setAttribute('cy', g.cy); element.setAttribute('r', g.r); } else if (node.type === 'arc') { const large = g.end - g.start > Math.PI ? 1 : 0, outerStart = `${g.cx + g.r * Math.cos(g.start)} ${g.cy + g.r * Math.sin(g.start)}`, outerEnd = `${g.cx + g.r * Math.cos(g.end)} ${g.cy + g.r * Math.sin(g.end)}`; const p = g.innerR > 0 ? [`M ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, `L ${g.cx + g.innerR * Math.cos(g.end)} ${g.cy + g.innerR * Math.sin(g.end)}`, `A ${g.innerR} ${g.innerR} 0 ${large} 0 ${g.cx + g.innerR * Math.cos(g.start)} ${g.cy + g.innerR * Math.sin(g.start)}`, 'Z'] : [`M ${g.cx} ${g.cy}`, `L ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, 'Z']; element.setAttribute('d', p.join(' ')); } else if (node.type === 'path') { const d = g.curve === 'cubic' && g.points?.length === 4 ? `M ${g.points[0].x} ${g.points[0].y} C ${g.points[1].x} ${g.points[1].y} ${g.points[2].x} ${g.points[2].y} ${g.points[3].x} ${g.points[3].y}` : `${g.points.map((p, i) => `${i ? 'L' : 'M'} ${p.x} ${p.y}`).join(' ')}${g.closed ? ' Z' : ''}`; element.setAttribute('d', d); } else if (node.type === 'text') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); if (Number.isFinite(Number(s.rotation)) && Number(s.rotation) !== 0) element.setAttribute('transform', `rotate(${Number(s.rotation)} ${g.x} ${g.y})`); element.textContent = g.text; const anchor = s.textAnchor || (s.textAlign === 'end' ? 'end' : s.textAlign === 'center' ? 'middle' : s.textAlign === 'right' ? 'end' : s.textAlign === 'left' ? 'start' : 'start'); if (anchor) element.setAttribute('text-anchor', anchor); const baseline = s.textBaseline || s.baseline || 'alphabetic'; if (baseline === 'top') { element.setAttribute('dominant-baseline', 'text-before-edge'); element.setAttribute('alignment-baseline', 'before-edge'); } else if (baseline === 'middle' || baseline === 'central') { element.setAttribute('dominant-baseline', 'middle'); element.setAttribute('alignment-baseline', 'middle'); } else if (baseline === 'bottom' || baseline === 'hanging') { element.setAttribute('dominant-baseline', 'text-after-edge'); element.setAttribute('alignment-baseline', 'after-edge'); } else { element.setAttribute('dominant-baseline', 'alphabetic'); element.setAttribute('alignment-baseline', 'alphabetic'); } if (s.font) { const raw = String(s.font).trim(); element.setAttribute('font', raw); const sizeMatch = raw.match(/\b(\d+(?:\.\d+)?)\s*(px|em|rem|pt|%)/i); const size = sizeMatch ? `${sizeMatch[1]}${sizeMatch[2].toLowerCase()}` : ''; if (size) element.style.fontSize = size; const weightMatch = raw.match(/^\s*(\d{3}|normal|bold|lighter|bolder)\b/i); if (weightMatch) element.style.fontWeight = weightMatch[1]; const family = raw.replace(/^\s*(?:(?:normal|italic|oblique)(?:\s+[^0-9\s]+)?\s+)?(?:\d{3}|normal|bold|lighter|bolder)\s+/i, '').replace(/\b\d+(?:\.\d+)?\s*(?:px|em|rem|pt|%)(?:\s*\/\s*\S+)?\s*/i, '').trim(); if (family) element.style.fontFamily = family; } } if (node.type === 'path' || node.type === 'circle' || node.type === 'rect' || node.type === 'arc') { element.setAttribute('fill', hasFill(s) ? s.fill : 'none'); } else if (node.type === 'text') { element.setAttribute('fill', hasFill(s) ? s.fill : '#0f172a'); } if (hasStroke(s)) element.setAttribute('stroke', s.stroke); else if (s.stroke != null && typeof s.stroke === 'string' && NO_FILL_TOKENS.has(s.stroke.trim().toLowerCase())) element.setAttribute('stroke', 'none'); if (s.strokeWidth != null) element.setAttribute('stroke-width', s.strokeWidth); if (s.opacity != null) element.setAttribute('opacity', node.highlighted || node.selected ? 1 : s.opacity); if (node.highlighted || node.selected) element.setAttribute('filter', 'brightness(1.2)'); if (node.interactive) element.style.cursor = 'pointer'; if (s.pointerEvents === 'none') element.style.pointerEvents = 'none'; if (s.ariaHidden === 'true') element.setAttribute('aria-hidden', 'true'); if (s.role === 'presentation') element.setAttribute('role', 'presentation'); if (node.decorative) { element.setAttribute('aria-hidden', 'true'); element.setAttribute('role', 'presentation'); element.style.pointerEvents = 'none'; element.style.userSelect = 'none'; } this.svg.appendChild(element); node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }; scene.root.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }
89
+ this.clear(); const renderNode = node => { if (!node.visible || node.type === 'root') { node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); return; } const g = node.geometry || {}, s = node.style || {}; const element = document.createElementNS('http://www.w3.org/2000/svg', node.type === 'path' || node.type === 'arc' ? 'path' : node.type === 'text' ? 'text' : node.type); element.setAttribute('id', node.id); element.setAttribute('data-node-id', node.id); if (node.dataRef) element.dataset.dataRef = JSON.stringify(node.dataRef); if (node.type === 'rect') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); element.setAttribute('width', g.width); element.setAttribute('height', g.height); } else if (node.type === 'line') { ['x1', 'y1', 'x2', 'y2'].forEach(key => element.setAttribute(key, g[key])); } else if (node.type === 'circle') { element.setAttribute('cx', g.cx); element.setAttribute('cy', g.cy); element.setAttribute('r', g.r); } else if (node.type === 'ellipse') { element.setAttribute('cx', g.cx); element.setAttribute('cy', g.cy); element.setAttribute('rx', g.rx); element.setAttribute('ry', g.ry); } else if (node.type === 'arc') { const large = g.end - g.start > Math.PI ? 1 : 0, outerStart = `${g.cx + g.r * Math.cos(g.start)} ${g.cy + g.r * Math.sin(g.start)}`, outerEnd = `${g.cx + g.r * Math.cos(g.end)} ${g.cy + g.r * Math.sin(g.end)}`; const p = g.innerR > 0 ? [`M ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, `L ${g.cx + g.innerR * Math.cos(g.end)} ${g.cy + g.innerR * Math.sin(g.end)}`, `A ${g.innerR} ${g.innerR} 0 ${large} 0 ${g.cx + g.innerR * Math.cos(g.start)} ${g.cy + g.innerR * Math.sin(g.start)}`, 'Z'] : [`M ${g.cx} ${g.cy}`, `L ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, 'Z']; element.setAttribute('d', p.join(' ')); } else if (node.type === 'path') { const d = g.curve === 'cubic' && g.points?.length === 4 ? `M ${g.points[0].x} ${g.points[0].y} C ${g.points[1].x} ${g.points[1].y} ${g.points[2].x} ${g.points[2].y} ${g.points[3].x} ${g.points[3].y}` : `${g.points.map((p, i) => `${i ? 'L' : 'M'} ${p.x} ${p.y}`).join(' ')}${g.closed ? ' Z' : ''}`; element.setAttribute('d', d); } else if (node.type === 'text') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); if (Number.isFinite(Number(s.rotation)) && Number(s.rotation) !== 0) element.setAttribute('transform', `rotate(${Number(s.rotation)} ${g.x} ${g.y})`); element.textContent = g.text; const anchor = s.textAnchor || (s.textAlign === 'end' ? 'end' : s.textAlign === 'center' ? 'middle' : s.textAlign === 'right' ? 'end' : s.textAlign === 'left' ? 'start' : 'start'); if (anchor) element.setAttribute('text-anchor', anchor); const baseline = s.textBaseline || s.baseline || 'alphabetic'; if (baseline === 'top') { element.setAttribute('dominant-baseline', 'text-before-edge'); element.setAttribute('alignment-baseline', 'before-edge'); } else if (baseline === 'middle' || baseline === 'central') { element.setAttribute('dominant-baseline', 'middle'); element.setAttribute('alignment-baseline', 'middle'); } else if (baseline === 'bottom' || baseline === 'hanging') { element.setAttribute('dominant-baseline', 'text-after-edge'); element.setAttribute('alignment-baseline', 'after-edge'); } else { element.setAttribute('dominant-baseline', 'alphabetic'); element.setAttribute('alignment-baseline', 'alphabetic'); } if (s.font) { const raw = String(s.font).trim(); element.setAttribute('font', raw); const sizeMatch = raw.match(/\b(\d+(?:\.\d+)?)\s*(px|em|rem|pt|%)/i); const size = sizeMatch ? `${sizeMatch[1]}${sizeMatch[2].toLowerCase()}` : ''; if (size) element.style.fontSize = size; const weightMatch = raw.match(/^\s*(\d{3}|normal|bold|lighter|bolder)\b/i); if (weightMatch) element.style.fontWeight = weightMatch[1]; const family = raw.replace(/^\s*(?:(?:normal|italic|oblique)(?:\s+[^0-9\s]+)?\s+)?(?:\d{3}|normal|bold|lighter|bolder)\s+/i, '').replace(/\b\d+(?:\.\d+)?\s*(?:px|em|rem|pt|%)(?:\s*\/\s*\S+)?\s*/i, '').trim(); if (family) element.style.fontFamily = family; } } if (node.type === 'path' || node.type === 'circle' || node.type === 'ellipse' || node.type === 'rect' || node.type === 'arc') { element.setAttribute('fill', hasFill(s) ? s.fill : 'none'); } else if (node.type === 'text') { element.setAttribute('fill', hasFill(s) ? s.fill : '#0f172a'); } if (hasStroke(s)) element.setAttribute('stroke', s.stroke); else if (s.stroke != null && typeof s.stroke === 'string' && NO_FILL_TOKENS.has(s.stroke.trim().toLowerCase())) element.setAttribute('stroke', 'none'); if (s.strokeWidth != null) element.setAttribute('stroke-width', s.strokeWidth); if (s.opacity != null) element.setAttribute('opacity', node.highlighted || node.selected ? 1 : s.opacity); if (node.highlighted || node.selected) element.setAttribute('filter', 'brightness(1.2)'); if (node.interactive) element.style.cursor = 'pointer'; if (s.pointerEvents === 'none') element.style.pointerEvents = 'none'; if (s.ariaHidden === 'true') element.setAttribute('aria-hidden', 'true'); if (s.role === 'presentation') element.setAttribute('role', 'presentation'); if (node.decorative) { element.setAttribute('aria-hidden', 'true'); element.setAttribute('role', 'presentation'); element.style.pointerEvents = 'none'; element.style.userSelect = 'none'; } this.svg.appendChild(element); node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }; scene.root.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }
90
90
  exportString() { if (!this.svg) return ''; return new XMLSerializer().serializeToString(this.svg); }
91
91
  }
package/src/scene.mjs CHANGED
@@ -37,6 +37,11 @@ function geometryHit(node, x, y) {
37
37
  const points = geometry.curve === 'cubic' ? sampleCubicBezier(geometry.points) : geometry.points;
38
38
  return points.some((point, index) => index > 0 && pointToSegmentDistance({ x, y }, points[index - 1], point) <= tolerance);
39
39
  }
40
+ if (node.type === 'ellipse') {
41
+ const rx = Math.max(1, Number(geometry.rx) || 0) + tolerance;
42
+ const ry = Math.max(1, Number(geometry.ry) || 0) + tolerance;
43
+ return ((x - geometry.cx) / rx) ** 2 + ((y - geometry.cy) / ry) ** 2 <= 1;
44
+ }
40
45
  return Boolean(node.bounds && x >= node.bounds.x && x <= node.bounds.x + node.bounds.width && y >= node.bounds.y && y <= node.bounds.y + node.bounds.height);
41
46
  }
42
47
 
package/src/spec.mjs CHANGED
@@ -118,6 +118,7 @@ const presentationEncodingKeys = new Set(['title', 'format', 'labels', 'legend']
118
118
  const diagramTypes = new Set(['flow', 'swimlane', 'architecture', 'mindmap']);
119
119
  const diagramFields = ['nodes', 'edges', 'lanes', 'groups', 'layers', 'boundaries'];
120
120
  const nonCartesianAnalysisTypes = new Set(['pie', 'funnel', 'gauge', 'heatmap', 'radar']);
121
+ const knownSpecKeys = new Set(['version', 'type', 'renderer', 'container', 'chartId', 'width', 'height', 'padding', 'colors', 'background', 'locale', 'data', 'encoding', 'title', 'legend', 'grid', 'labels', 'xAxis', 'yAxis', 'domain', 'colorScale', 'indicators', 'innerRadius', 'stack', 'transform', 'criticalPath', 'nodes', 'edges', 'lanes', 'layers', 'boundaries', 'groups', 'diagram', 'project', 'interaction', 'editing', 'accessibility', 'branding', 'theme', 'preferences', 'preferencesStore', 'plugins', 'schema', 'validationOptions', 'emptyText', 'responsive', 'view', 'context', 'intent', 'tasks', 'events']);
121
122
 
122
123
  function finiteDomain(domain) {
123
124
  return Array.isArray(domain) && domain.length === 2 && domain.every(value => Number.isFinite(Number(value))) && Number(domain[1]) > Number(domain[0]);
@@ -179,11 +180,12 @@ export function normalizeSpec(input = {}) {
179
180
  export function validateSpec(input = {}) {
180
181
  const spec = normalizeSpec(input);
181
182
  const errors = [], warnings = [], normalizations = [];
183
+ Object.keys(input || {}).filter(key => !knownSpecKeys.has(key)).forEach(key => warnings.push({ code: 'UNKNOWN_SPEC_OPTION', path: key, message: `Top-level option ${key} is not part of the iChart.js Spec contract and will be ignored.`, expected: [...knownSpecKeys].sort(), suggestion: 'Remove the option or place host metadata outside the chart Spec.' }));
182
184
  if (typeof input.title === 'string') {
183
- normalizations.push({ path: 'title', from: 'string', to: 'title.text' });
185
+ normalizations.push({ code: 'NORMALIZED_TITLE', path: 'title', from: 'string', to: 'title.text' });
184
186
  warnings.push({ code: 'NORMALIZED_TITLE', path: 'title', message: 'String titles are normalized to title.text.', suggestion: 'Prefer title: { text: "..." } for an explicit title contract.' });
185
187
  } else if (input.title && typeof input.title === 'object' && !Array.isArray(input.title) && input.title.text === undefined && typeof input.title.label === 'string') {
186
- normalizations.push({ path: 'title.label', from: 'title.label', to: 'title.text' });
188
+ normalizations.push({ code: 'NORMALIZED_TITLE', path: 'title.label', from: 'title.label', to: 'title.text' });
187
189
  warnings.push({ code: 'NORMALIZED_TITLE', path: 'title.label', message: 'title.label is normalized to title.text.', suggestion: 'Use title: { text: "..." }.' });
188
190
  } else if (input.title !== undefined && (!input.title || typeof input.title !== 'object' || Array.isArray(input.title) || (input.title.text === undefined && input.title.subtitle === undefined))) {
189
191
  warnings.push({ code: 'INVALID_TITLE', path: 'title', message: 'Title must be a string or an object with text and/or subtitle.', suggestion: 'Use title: { text: "...", subtitle: "..." }.' });