@taylorwong/ichartjs 2.0.18 → 2.0.20

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.
@@ -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.18/skills/ichartjs \
123
+ npx skills add https://github.com/wanghetommy/ichartjs/tree/v2.0.20/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.18",
286
- "runtimeVersion": "2.0.18",
285
+ "packageVersion": "2.0.20",
286
+ "runtimeVersion": "2.0.20",
287
287
  "chartTypes": [
288
288
  "line",
289
289
  "area",
@@ -1408,5 +1408,35 @@
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
+ }
1412
1442
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taylorwong/ichartjs",
3
- "version": "2.0.18",
3
+ "version": "2.0.20",
4
4
  "description": "Agent-first, renderer-independent charting and project visualization runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -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.18/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.20/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
 
@@ -42,12 +43,13 @@ Do not install the unscoped npm registry package named `ichartjs`; it is current
42
43
  7. Build a JSON-serializable Spec using `suggestedEncodings`, the selected capability, and an applicable recipe.
43
44
  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
45
  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 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`, `NEGATIVE_VALUE_DROPPED`, and `ZERO_TOTAL`; do not assume every layout warning is available during preflight.
46
+ 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
47
  11. Call `createChart()` only after validation succeeds. Gauge Specs must declare a meaningful `domain`.
47
48
  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
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
 
@@ -64,6 +66,7 @@ Route by requested output:
64
66
  - For standard data analysis, read `references/chart-selection.md` and use foundational recipes.
65
67
  - For Gantt, Timeline, Milestone, Burndown, capacity, release, risk, or aging, use project capabilities and `agent-recipes/project-management.json`.
66
68
  - For Flow or Swimlane, preserve node, edge, lane, group, and port IDs; use diagram recipes and validated edit commands.
69
+ - 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
70
  - For business edits, preview first, preserve the preview ID and revision, require confirmation when declared, then commit or reject atomically.
68
71
  - For browser deliverables, start `npm run playground` and return the exact maintained Playground URL.
69
72
  - 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.
@@ -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 = {}) {
@@ -223,6 +223,20 @@ export function getCapabilities() {
223
223
  data: ['normalize', 'inspect', 'filter', 'sort', 'groupBy', 'sum', 'average', 'topN', 'percentage', 'bin'],
224
224
  themes: [...styleCapabilities.modes],
225
225
  styleSystem: JSON.parse(JSON.stringify(styleCapabilities)),
226
+ conversationalWorkflow: {
227
+ version: '1.0',
228
+ steps: ['read-state', 'classify-request', 'build-validated-change', 'preview-or-validate', 'confirm-when-required', 'commit', 'explain-and-report'],
229
+ routing: {
230
+ visual: 'setPreferences or setTheme',
231
+ replaceData: 'setData',
232
+ businessData: 'previewEdit then applyEdit',
233
+ spec: 'update',
234
+ jsonPointer: 'applyPatch',
235
+ diagram: 'previewEdit then applyEdit'
236
+ },
237
+ confirmation: ['deletion', 'structural-change', 'bulk-edit'],
238
+ selfCheck: ['chart.explain()', 'chart.getState()', 'chart.getState().health']
239
+ },
226
240
  preferences: {
227
241
  version: '1.0',
228
242
  scopes: preferenceCapabilities.scopes,
@@ -231,6 +245,13 @@ export function getCapabilities() {
231
245
  agentAdjustable: true,
232
246
  interactiveSettingsUI: true,
233
247
  precedence: preferenceCapabilities.precedence,
248
+ discovery: {
249
+ capabilities: 'getPreferenceCapabilities(chartType?, { locale? })',
250
+ current: 'chart.getPreferences()',
251
+ validate: 'validatePreferences(patch, { partial: true })',
252
+ apply: "chart.setPreferences(patch, { scope, source: 'agent' })",
253
+ verify: 'chart.getState().preferences'
254
+ },
234
255
  schema: preferenceCapabilities
235
256
  },
236
257
  commands: [...commandTypes],
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, middle = angle + (end - angle) / 2, fill = colors[index % colors.length], dataRef = { seriesIndex: 0, dataIndex: index, category: row[categoryField], value, rawValue }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'arc', geometry: { cx, cy, r: radius, innerR, start: angle, end }, bounds: { x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2 }, style: { fill, stroke: spec.theme.background, strokeWidth: 1 }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, value / total, cx + Math.cos(middle) * radius * .72, cy + Math.sin(middle) * radius * .72, dataRef, state.plot, null, spec.labels?.format || { style: 'percent' }, fill); angle = end; });
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
- scene.add(new SceneNode({ id: 'gauge-background', type: 'arc', geometry: { cx, cy, r: radius, start: Math.PI, end: Math.PI * 2 }, style: { fill: spec.theme.grid } })); scene.add(new SceneNode({ id: 'gauge-value', type: 'arc', geometry: { cx, cy, r: radius, start, end }, style: { fill: colors[0] }, dataRef: { seriesIndex: 0, dataIndex: 0, value, rawValue: numericValue }, interactive: true })); addText(scene, 'gauge-label', formatValue(numericValue, spec.labels?.format || spec.encoding.value?.format || { maximumFractionDigits: 2 }, spec.locale), cx, cy - 12, { fill: spec.theme.text, font: font(spec, 'metric'), textAnchor: 'middle' });
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
  }
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,7 +459,7 @@ 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(); }
@@ -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.18', 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.20', 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)));
@@ -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