@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.
- package/CHANGELOG.md +12 -0
- package/README.md +3 -2
- package/docs/agent/charting-scenario.md +2 -0
- package/docs/agent/coding-agent-integration.md +1 -1
- package/docs/agent/conversational-workflow.md +134 -0
- package/docs/agent/development/2.0-release-readiness.md +1 -1
- package/docs/agent/development/iteration-15.md +48 -0
- package/docs/agent/development/iteration-16.md +70 -0
- package/docs/agent/development/roadmap.md +20 -2
- package/docs/agent/diagram-scenario.md +5 -1
- package/docs/agent/frontend-integration.md +1 -1
- package/docs/agent/runtime-contract.md +2 -0
- package/docs/agent/usage-scenarios.md +5 -1
- package/docs/agent/zh-CN/charting-scenario.md +2 -0
- package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
- package/docs/agent/zh-CN/conversational-workflow.md +128 -0
- package/docs/agent/zh-CN/diagram-scenario.md +5 -1
- package/docs/agent/zh-CN/frontend-integration.md +1 -1
- package/docs/agent/zh-CN/runtime-contract.md +2 -0
- package/docs/agent/zh-CN/usage-scenarios.md +5 -1
- package/docs/manifests/capabilities.json +35 -5
- package/package.json +1 -1
- package/skills/ichartjs/SKILL.md +5 -2
- package/src/capabilities.mjs +23 -2
- package/src/charts.mjs +17 -5
- package/src/index.mjs +10 -6
- package/src/layout.mjs +68 -0
- package/src/preferences.mjs +24 -1
- package/src/project.mjs +101 -12
- package/src/renderer.mjs +2 -2
- package/src/scene.mjs +5 -0
- package/types/index.d.ts +5 -3
|
@@ -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.
|
|
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.
|
|
286
|
-
"runtimeVersion": "2.0.
|
|
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
package/skills/ichartjs/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
package/src/capabilities.mjs
CHANGED
|
@@ -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: [
|
|
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,
|
|
435
|
+
else data.rows.forEach((row, index) => { const rawValue = values[index], value = Number.isFinite(rawValue) ? Math.max(0, rawValue) : 0, end = angle + value / total * Math.PI * 2, fill = colors[index % colors.length], dataRef = { seriesIndex: 0, dataIndex: index, category: row[categoryField], value, rawValue }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'arc', geometry: { cx, cy, r: radius, innerR, start: angle, end }, bounds: { x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2 }, style: { fill, stroke: spec.theme.background, strokeWidth: 1 }, dataRef, interactive: true })); if (spec.labels?.enabled) { const label = formatValue(value / total, spec.labels.format || { style: 'percent' }, spec.locale), placement = polarLabelLayout(label, { cx, cy, outerRadius: radius, innerRadius: innerR, startAngle: angle, endAngle: end, size: fontSize(spec, 'label', 12), minSize: 10, padding: 4 }); addText(scene, `series-0-item-${index}-label`, label, placement.x, placement.y, { fill: readableLabelColor(spec, fill), font: fontAtSize(spec.labels.font || font(spec, 'label'), placement.size), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }, dataRef); polarLabels.visible += 1; if (placement.scaled) polarLabels.scaled += 1; if (placement.overflowed) polarLabels.overflowed += 1; } angle = end; });
|
|
436
|
+
state.labelLayout = { ...(state.labelLayout || {}), polar: polarLabels };
|
|
437
|
+
if (polarLabels.overflowed) data.warnings.push({ code: 'POLAR_LABEL_OVERFLOW', path: 'labels', count: polarLabels.overflowed, message: `${polarLabels.overflowed} pie label${polarLabels.overflowed === 1 ? '' : 's'} exceeded the available sector space and may overflow its sector.`, suggestion: 'Increase chart size, reduce the number of slices, or rely on the legend for small sectors.' });
|
|
431
438
|
} else if (spec.type === 'funnel') {
|
|
432
439
|
const categoryField = spec.encoding.category?.field || 'name', valueField = spec.encoding.value?.field || 'value'; const maxValue = Math.max(...data.rows.map(row => Number(row[valueField]) || 0), 1); const segmentHeight = state.plot.height / data.rows.length;
|
|
433
440
|
data.rows.forEach((row, index) => { const value = Number(row[valueField]) || 0, category = String(row[categoryField] ?? row.name ?? row.id ?? `Stage ${index + 1}`), ratio = Math.max(0.1, value / maxValue), fill = colors[index % colors.length]; const width = state.plot.width * ratio; const geometry = { x: state.plot.x + (state.plot.width - width) / 2, y: state.plot.y + index * segmentHeight, width, height: Math.max(2, segmentHeight - 3) }, dataRef = { seriesIndex: 0, dataIndex: index, field: valueField, category, value }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill }, dataRef, interactive: true })); const labelSize = fontSize(spec, 'label', 12), textColor = readableLabelColor(spec, fill), availableWidth = Math.max(labelSize * 2, width - 12), label = truncateText(category, availableWidth, labelSize); if (label !== category) data.warnings.push({ code: 'FUNNEL_LABEL_TRUNCATED', path: `data.values[${index}].${categoryField}`, index, rawLabel: category, renderedLabel: label, availableWidth, message: `Funnel stage label "${category}" was truncated to "${label}" because the stage is too narrow.`, suggestion: 'Increase chart width, shorten the stage label, or use a wider stage/value distribution.' }); addText(scene, `funnel-category-${index}`, label, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 - (spec.labels?.enabled ? labelSize * 0.6 : 0), { fill: textColor, font: spec.labels?.font || font(spec, 'label'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }, dataRef); if (spec.labels?.enabled) addText(scene, `funnel-value-${index}`, formatValue(value, spec.labels.format, spec.locale), geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 + labelSize * 0.65, { fill: textColor, font: spec.labels?.font || font(spec, 'label'), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' }, dataRef); });
|
|
@@ -435,9 +442,14 @@ export function buildScene(spec) {
|
|
|
435
442
|
const valueField = spec.encoding.value?.field || 'value', domain = Array.isArray(spec.domain) ? spec.domain : [0, 100], rawValue = Number(data.rows[0]?.[valueField]), numericValue = Number.isFinite(rawValue) ? rawValue : 0, value = Math.max(Number(domain[0]), Math.min(Number(domain[1]), numericValue)), ratio = (value - Number(domain[0])) / (Number(domain[1]) - Number(domain[0]) || 1), cx = state.plot.x + state.plot.width / 2, cy = state.plot.y + state.plot.height * 0.68, radius = Math.min(state.plot.width * 0.42, state.plot.height * 0.62), start = Math.PI, end = start + Math.PI * ratio;
|
|
436
443
|
if (!Array.isArray(spec.domain)) data.warnings.push({ code: 'MISSING_GAUGE_DOMAIN', path: 'domain', message: 'Gauge rendered with a compatibility fallback; provide an explicit domain to make the value meaningful.' });
|
|
437
444
|
if (numericValue < Number(domain[0]) || numericValue > Number(domain[1])) data.warnings.push({ code: 'VALUE_CLAMPED', path: `data.values[0].${valueField}`, count: 1, rawValue: numericValue, domain: [...domain], message: `Gauge value ${numericValue} is outside domain [${domain[0]}, ${domain[1]}] and was clamped for the arc.`, suggestion: 'Choose a domain that covers the value or review the source unit.' });
|
|
438
|
-
|
|
445
|
+
const label = formatValue(numericValue, spec.labels?.format || spec.encoding.value?.format || { maximumFractionDigits: 2 }, spec.locale), placement = polarLabelLayout(label, { position: 'center', cx, cy, outerRadius: radius, size: fontSize(spec, 'metric', 24), minSize: 12, maxWidth: radius * 1.25, padding: 4, offsetRatio: 0.36 });
|
|
446
|
+
scene.add(new SceneNode({ id: 'gauge-background', type: 'arc', geometry: { cx, cy, r: radius, start: Math.PI, end: Math.PI * 2 }, style: { fill: spec.theme.grid } })); scene.add(new SceneNode({ id: 'gauge-value', type: 'arc', geometry: { cx, cy, r: radius, start, end }, style: { fill: colors[0] }, dataRef: { seriesIndex: 0, dataIndex: 0, value, rawValue: numericValue }, interactive: true })); addText(scene, 'gauge-label', label, placement.x, placement.y, { fill: spec.theme.text, font: fontAtSize(font(spec, 'metric'), placement.size), textAnchor: 'middle', textBaseline: 'middle', baseline: 'middle' });
|
|
447
|
+
state.labelLayout = { ...(state.labelLayout || {}), polar: { visible: 1, scaled: placement.scaled ? 1 : 0, overflowed: placement.overflowed ? 1 : 0, suppressed: 0 } };
|
|
448
|
+
if (placement.overflowed) data.warnings.push({ code: 'POLAR_LABEL_OVERFLOW', path: 'labels', count: 1, message: 'The Gauge value label exceeds the available body width.', suggestion: 'Increase chart size or reduce the metric font size.' });
|
|
439
449
|
}
|
|
440
450
|
addLegend(scene, spec, state.chrome.legend);
|
|
451
|
+
state.labelLayout = { ...(state.labelLayout || {}), marks: scene._labelLayout };
|
|
452
|
+
if (scene._labelLayout.suppressed) data.warnings.push({ code: 'LABELS_SUPPRESSED', path: 'labels', count: scene._labelLayout.suppressed, message: `${scene._labelLayout.suppressed} data labels were suppressed because there was no collision-free placement.`, suggestion: 'Increase chart size, reduce label density, or disable labels.' });
|
|
441
453
|
addBranding(scene, spec);
|
|
442
454
|
return { scene, data, state };
|
|
443
455
|
}
|
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
|
-
|
|
446
|
-
|
|
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.
|
|
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)));
|
package/src/preferences.mjs
CHANGED
|
@@ -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:
|
|
178
|
+
storage: storageKind
|
|
156
179
|
};
|
|
157
180
|
}
|
|
158
181
|
|