@taylorwong/ichartjs 2.0.4 → 2.0.6
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 +14 -0
- package/README.md +14 -4
- package/docs/agent/README.md +1 -0
- package/docs/agent/coding-agent-integration.md +10 -2
- package/docs/agent/development/iteration-10.md +32 -0
- package/docs/agent/development/iteration-11.md +44 -0
- package/docs/agent/development/release-sop.md +2 -2
- package/docs/agent/development/roadmap.md +28 -2
- package/docs/agent/frontend-integration.md +5 -1
- package/docs/agent/quickstart.md +4 -1
- package/docs/agent/runtime-contract.md +5 -5
- package/docs/agent/theme-guide.md +31 -0
- package/docs/agent/usage-scenarios.md +198 -0
- package/docs/agent/zh-CN/README.md +1 -0
- package/docs/agent/zh-CN/coding-agent-integration.md +6 -2
- package/docs/agent/zh-CN/frontend-integration.md +1 -1
- package/docs/agent/zh-CN/quickstart.md +3 -3
- package/docs/agent/zh-CN/runtime-contract.md +5 -5
- package/docs/agent/zh-CN/theme-guide.md +31 -0
- package/docs/agent/zh-CN/usage-scenarios.md +189 -0
- package/docs/manifests/capabilities.json +13 -4
- package/package.json +1 -1
- package/skills/ichartjs/SKILL.md +19 -1
- package/src/capabilities.mjs +12 -3
- package/src/index.mjs +105 -53
- package/src/preferences-ui.mjs +86 -0
- package/src/preferences.mjs +194 -0
- package/src/renderer.mjs +3 -3
- package/src/spec.mjs +3 -0
- package/types/index.d.ts +21 -3
package/src/index.mjs
CHANGED
|
@@ -20,11 +20,14 @@ import { diagramLayoutModes, edgeRoutingModes, normalizeDiagramSpec, validateDia
|
|
|
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, planChart } from './capabilities.mjs';
|
|
23
|
+
import { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, normalizePreferences } from './preferences.mjs';
|
|
24
|
+
import { mountChartSettings } from './preferences-ui.mjs';
|
|
23
25
|
|
|
24
26
|
import { isDiagram, paintSelection, diagramPointer, diagramKeyboard } from './diagram-interaction.mjs';
|
|
25
27
|
|
|
26
28
|
function resolveContainer(container) { return typeof container === 'string' ? document.querySelector(container) : container; }
|
|
27
29
|
function clone(value) { return value == null ? value : JSON.parse(JSON.stringify(value)); }
|
|
30
|
+
function isPreferencesStore(value) { return Boolean(value && typeof value.getEffective === 'function' && typeof value.setChart === 'function' && typeof value.subscribe === 'function'); }
|
|
28
31
|
export function resolveZoomWindow(count, current, factor) {
|
|
29
32
|
const total = Math.max(0, Math.round(Number(count) || 0));
|
|
30
33
|
if (total <= 1) return { start: 0, end: total };
|
|
@@ -54,6 +57,12 @@ function _svgStyleFontParts(raw = '') {
|
|
|
54
57
|
.trim();
|
|
55
58
|
return { size, weight, family };
|
|
56
59
|
}
|
|
60
|
+
const NO_PAINT_TOKENS = new Set(['none', 'transparent', '']);
|
|
61
|
+
function hasPaint(style, key) {
|
|
62
|
+
const value = style?.[key];
|
|
63
|
+
if (value == null) return false;
|
|
64
|
+
return typeof value !== 'string' || !NO_PAINT_TOKENS.has(value.trim().toLowerCase());
|
|
65
|
+
}
|
|
57
66
|
function sceneToSvgString(scene, spec = {}) {
|
|
58
67
|
const w = Number(scene?.width ?? spec?.width ?? 640);
|
|
59
68
|
const h = Number(scene?.height ?? spec?.height ?? 360);
|
|
@@ -114,9 +123,10 @@ function sceneToSvgString(scene, spec = {}) {
|
|
|
114
123
|
} else {
|
|
115
124
|
tag = 'g';
|
|
116
125
|
}
|
|
117
|
-
if (
|
|
118
|
-
else if (
|
|
119
|
-
if (s
|
|
126
|
+
if (['path', 'circle', 'rect', 'arc'].includes(node.type)) attrs.push(`fill="${hasPaint(s, 'fill') ? _svgEscape(s.fill, 'attr') : 'none'}"`);
|
|
127
|
+
else if (node.type === 'text') attrs.push(`fill="${hasPaint(s, 'fill') ? _svgEscape(s.fill, 'attr') : '#0f172a'}"`);
|
|
128
|
+
if (hasPaint(s, 'stroke')) attrs.push(`stroke="${_svgEscape(s.stroke, 'attr')}"`);
|
|
129
|
+
else if (s.stroke != null) attrs.push('stroke="none"');
|
|
120
130
|
if (s.strokeWidth) attrs.push(`stroke-width="${s.strokeWidth}"`);
|
|
121
131
|
if (s.opacity != null) {
|
|
122
132
|
const opacity = (node.highlighted || node.selected) ? 1 : s.opacity;
|
|
@@ -145,15 +155,49 @@ function sceneToSvgString(scene, spec = {}) {
|
|
|
145
155
|
lines.push(`</svg>`);
|
|
146
156
|
return lines.join('\n');
|
|
147
157
|
}
|
|
158
|
+
function exportError(code, message, suggestion, extra = {}) { return { valid: false, code, message, suggestion, ...extra }; }
|
|
159
|
+
function dataUrlToBlob(dataUrl, mime) {
|
|
160
|
+
if (typeof Blob === 'undefined') return exportError('BLOB_HEADLESS', 'Blob is not available in this runtime.', 'Use as=dataurl or as=string.');
|
|
161
|
+
const comma = dataUrl.indexOf(',');
|
|
162
|
+
if (comma < 0) return exportError('EXPORT_UNSUPPORTED', 'The renderer returned an invalid data URL.', 'Use chart.export({ type: "svg" }) and inspect the returned string.');
|
|
163
|
+
const header = dataUrl.slice(0, comma), payload = dataUrl.slice(comma + 1);
|
|
164
|
+
const bytes = /;base64$/i.test(header)
|
|
165
|
+
? Uint8Array.from(atob(payload), value => value.charCodeAt(0))
|
|
166
|
+
: new TextEncoder().encode(decodeURIComponent(payload));
|
|
167
|
+
return new Blob([bytes], { type: mime });
|
|
168
|
+
}
|
|
169
|
+
function canvasFactoryFromModule(module) { return module?.createCanvas || module?.default?.createCanvas || null; }
|
|
170
|
+
function renderRasterWithCanvas(createCanvas, spec, scene, rasterType, as) {
|
|
171
|
+
const canvas = createCanvas(spec.width, spec.height), renderer = new CanvasRenderer({ ...spec });
|
|
172
|
+
renderer.canvas = canvas;
|
|
173
|
+
renderer.ctx = canvas.getContext('2d');
|
|
174
|
+
if (!renderer.ctx) return exportError('HEADLESS_EXPORT_UNSUPPORTED', 'The optional canvas package did not provide a 2D context.', 'Install a supported canvas implementation or use SVG export.');
|
|
175
|
+
renderer.resize(spec.width, spec.height);
|
|
176
|
+
renderer.render(scene);
|
|
177
|
+
const dataUrl = renderer.exportImage(rasterType);
|
|
178
|
+
if (as === 'blob') return dataUrlToBlob(dataUrl, rasterType);
|
|
179
|
+
return dataUrl;
|
|
180
|
+
}
|
|
148
181
|
|
|
149
182
|
export class Chart {
|
|
150
183
|
constructor(input = {}) {
|
|
151
|
-
const
|
|
184
|
+
const specInput = { ...input };
|
|
185
|
+
delete specInput.preferences;
|
|
186
|
+
delete specInput.preferencesStore;
|
|
187
|
+
delete specInput.chartId;
|
|
188
|
+
const result = validateSpec(specInput);
|
|
152
189
|
if (!result.valid) { const error = new Error(result.errors.map(item => item.message).join(' ')); error.details = result.errors; throw error; }
|
|
153
190
|
this._specDiagnostics = { warnings: result.warnings, normalizations: result.normalizations };
|
|
191
|
+
this.chartId = input.chartId ?? input.id ?? null;
|
|
192
|
+
this._preferencesStore = isPreferencesStore(input.preferencesStore) ? input.preferencesStore : isPreferencesStore(input.preferences) ? input.preferences : null;
|
|
193
|
+
this._localPreferences = this._preferencesStore ? null : normalizePreferences(input.preferences || {});
|
|
194
|
+
delete result.spec.preferences;
|
|
195
|
+
delete result.spec.preferencesStore;
|
|
196
|
+
delete result.spec.chartId;
|
|
154
197
|
this._themeInput = input.theme ?? 'auto';
|
|
155
198
|
this._styleOverrides = { colors: input.colors !== undefined, background: input.background !== undefined, padding: input.padding !== undefined };
|
|
156
199
|
this.spec = result.spec;
|
|
200
|
+
this._preferenceBase = Object.fromEntries(['legend', 'labels', 'grid', 'branding', 'padding'].map(key => [key, clone(this.spec[key])]));
|
|
157
201
|
this._resolveStyle();
|
|
158
202
|
this.container = typeof document === 'undefined' ? null : resolveContainer(this.spec.container);
|
|
159
203
|
this.listeners = new Map();
|
|
@@ -168,12 +212,26 @@ export class Chart {
|
|
|
168
212
|
this._observeResize();
|
|
169
213
|
this._observeColorScheme();
|
|
170
214
|
this.render();
|
|
215
|
+
this._preferencesSnapshot = JSON.stringify(this.getPreferences());
|
|
216
|
+
if (this._preferencesStore) this._preferencesUnsubscribe = this._preferencesStore.subscribe(event => {
|
|
217
|
+
const next = this._preferencesStore.getEffective(this.chartId);
|
|
218
|
+
const serialized = JSON.stringify(next);
|
|
219
|
+
if (serialized === this._preferencesSnapshot) return;
|
|
220
|
+
this._preferencesSnapshot = serialized;
|
|
221
|
+
this._resolveStyle();
|
|
222
|
+
this.render();
|
|
223
|
+
this.emit('preferenceschange', { chart: this, preferences: clone(next), source: event.source, scope: event.scope, persisted: event.persisted });
|
|
224
|
+
});
|
|
171
225
|
}
|
|
172
226
|
_resolveStyle() {
|
|
173
|
-
this.spec
|
|
227
|
+
['legend', 'labels', 'grid', 'branding'].forEach(key => { this.spec[key] = clone(this._preferenceBase[key]); });
|
|
228
|
+
if (this._styleOverrides.padding) this.spec.padding = clone(this._preferenceBase.padding);
|
|
229
|
+
const preferences = this.getPreferences();
|
|
230
|
+
this.spec.theme = resolveTheme(mergeThemePreference(this._themeInput, preferences.theme), this.spec);
|
|
174
231
|
if (!this._styleOverrides.colors) this.spec.colors = [...this.spec.theme.colors];
|
|
175
232
|
if (!this._styleOverrides.background) this.spec.background = this.spec.theme.background;
|
|
176
233
|
if (!this._styleOverrides.padding) this.spec.padding = clone(this.spec.theme.layout.padding);
|
|
234
|
+
this.spec = applyPreferencesToSpec(this.spec, preferences);
|
|
177
235
|
}
|
|
178
236
|
_observeColorScheme() {
|
|
179
237
|
if (typeof matchMedia !== 'function') return;
|
|
@@ -269,13 +327,16 @@ export class Chart {
|
|
|
269
327
|
on(type, listener) { if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type).add(listener); return this; }
|
|
270
328
|
off(type, listener) { this.listeners.get(type)?.delete(listener); return this; }
|
|
271
329
|
emit(type, event) { this.listeners.get(type)?.forEach(listener => listener(event)); }
|
|
272
|
-
update(next = {}) { this._editor.invalidate(); if (
|
|
330
|
+
update(next = {}) { this._editor.invalidate(); const { preferences, preferencesStore, chartId, ...specUpdate } = next || {}; if (preferences !== undefined) this.setPreferences(preferences); if (specUpdate.theme !== undefined) this._themeInput = specUpdate.theme; ['colors', 'background', 'padding'].forEach(key => { if (specUpdate[key] !== undefined) this._styleOverrides[key] = true; }); this.spec = normalizeSpec({ ...this.spec, ...specUpdate, theme: this._themeInput, data: specUpdate.data === undefined ? this.spec.data : specUpdate.data }); ['legend', 'labels', 'grid', 'branding', 'padding'].forEach(key => { if (specUpdate[key] !== undefined) this._preferenceBase[key] = clone(this.spec[key]); }); this._resolveStyle(); return this.render(); }
|
|
273
331
|
setData(data) { this._editor.invalidate(); this.spec = normalizeSpec({ ...this.spec, theme: this._themeInput, data: { values: data } }); this._resolveStyle(); return this.render(); }
|
|
274
332
|
setTheme(theme = 'auto') { this._themeInput = theme; this._resolveStyle(); this.render(); this.emit('themechange', { chart: this, theme: this.getTheme(), source: 'user' }); return this; }
|
|
275
333
|
getTheme() { return clone(this.spec.theme); }
|
|
334
|
+
getPreferences() { return this._preferencesStore ? this._preferencesStore.getEffective(this.chartId) : clone(this._localPreferences || defaultPreferences); }
|
|
335
|
+
setPreferences(patch = {}, options = {}) { if (this._preferencesStore) { const scope = options.scope === 'global' ? 'global' : 'chart'; if (scope === 'global') this._preferencesStore.setGlobal(patch, { ...options, source: options.source || 'user' }); else this._preferencesStore.setChart(this.chartId || 'default', patch, { ...options, source: options.source || 'user' }); return this; } this._localPreferences = mergePreferences(this._localPreferences, patch); this._preferencesSnapshot = JSON.stringify(this._localPreferences); this._resolveStyle(); this.render(); this.emit('preferenceschange', { chart: this, preferences: this.getPreferences(), source: options.source || 'user', scope: 'chart', persisted: false }); return this; }
|
|
336
|
+
resetPreferences(options = {}) { if (this._preferencesStore) { this._preferencesStore.reset({ ...options, scope: options.scope || 'chart', chartId: this.chartId || 'default' }); return this; } this._localPreferences = normalizePreferences({}); this._preferencesSnapshot = JSON.stringify(this._localPreferences); this._resolveStyle(); this.render(); this.emit('preferenceschange', { chart: this, preferences: this.getPreferences(), source: options.source || 'user', scope: 'chart', persisted: false }); return this; }
|
|
276
337
|
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; }
|
|
277
338
|
getSpec() { return JSON.parse(JSON.stringify(this.spec)); }
|
|
278
|
-
getState() { const brandingSignature = discoverCapabilities().branding.signature; 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 }), branding: { enabled: Boolean(this.spec.branding?.enabled), signature: brandingSignature, text: this.spec.branding?.enabled === true ? brandingSignature : null }, warnings: clone([...(this._specDiagnostics?.warnings || []), ...(this.model.data?.warnings || []), ...(this.spec.theme?.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) }; }
|
|
339
|
+
getState() { const brandingSignature = discoverCapabilities().branding.signature; 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 }), preferences: this.getPreferences(), branding: { enabled: Boolean(this.spec.branding?.enabled), signature: brandingSignature, text: this.spec.branding?.enabled === true ? brandingSignature : null }, warnings: clone([...(this._specDiagnostics?.warnings || []), ...(this.model.data?.warnings || []), ...(this.spec.theme?.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) }; }
|
|
279
340
|
getProjectAnalytics() { return clone(this.model.state?.projectAnalytics || null); }
|
|
280
341
|
getLinkedState() { return clone(this.model.state?.linked || null); }
|
|
281
342
|
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(); }
|
|
@@ -368,7 +429,11 @@ export class Chart {
|
|
|
368
429
|
if (this.renderer instanceof SVGRenderer) {
|
|
369
430
|
if (wantsSvg) {
|
|
370
431
|
const headlessEnv = typeof document === 'undefined' || typeof XMLSerializer === 'undefined';
|
|
371
|
-
const string = headlessEnv
|
|
432
|
+
const string = headlessEnv
|
|
433
|
+
? sceneToSvgString(this.model.scene, this.spec)
|
|
434
|
+
: this.renderer.container
|
|
435
|
+
? this.renderer.exportString()
|
|
436
|
+
: (() => { const renderer = new SVGRenderer(this.spec); renderer.resize(this.spec.width, this.spec.height); renderer.render(this.model.scene); return renderer.exportString(); })();
|
|
372
437
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(string)}`;
|
|
373
438
|
}
|
|
374
439
|
if (typeof document !== 'undefined') {
|
|
@@ -382,19 +447,17 @@ export class Chart {
|
|
|
382
447
|
}
|
|
383
448
|
return { valid: false, code: 'HEADLESS_EXPORT_UNSUPPORTED', rasterCode: 'RASTER_EXPORT_UNSUPPORTED', message: `SVG renderer raster ('${type}') in headless requires npm i canvas, or fall back to chart.export({type:"svg"}).`, suggestion: 'Use chart.export({type:"svg"}) for headless, or render with { renderer: "canvas" }.' };
|
|
384
449
|
}
|
|
385
|
-
if (this.renderer instanceof CanvasRenderer && typeof this.renderer.exportImage === 'function') return this.renderer.exportImage(type);
|
|
386
|
-
return
|
|
450
|
+
if (this.renderer instanceof CanvasRenderer && typeof this.renderer.exportImage === 'function' && this.renderer.canvas) return this.renderer.exportImage(type);
|
|
451
|
+
return exportError(typeof document === 'undefined' ? 'HEADLESS_EXPORT_UNSUPPORTED' : 'EXPORT_UNSUPPORTED', `toDataURL('${type}') is not available because the renderer has no canvas surface.`, 'Mount the chart in a browser or use chart.export({ type: "svg" }).');
|
|
387
452
|
}
|
|
388
453
|
toBlob(type = 'image/png') {
|
|
389
454
|
if (typeof document === 'undefined') return { valid: false, code: 'BLOB_HEADLESS', message: 'toBlob() requires a browser document. Use toDataURL() or export({type:"svg"}) in headless.', suggestion: 'Use chart.export({type:"svg"}) or chart.toDataURL() which both return strings usable in headless.' };
|
|
390
|
-
|
|
455
|
+
let url;
|
|
456
|
+
try { url = this.toDataURL(type); } catch (error) { return exportError('EXPORT_UNSUPPORTED', String(error?.message || error), 'Use chart.export({ type: "svg" }) or mount a supported renderer.'); }
|
|
391
457
|
if (!url || typeof url !== 'string') return { valid: false, code: 'EXPORT_UNSUPPORTED', message: "Couldn't produce a data URL for export.", suggestion: (url && url.message) || url };
|
|
392
|
-
const
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
const payload = comma >= 0 ? url.slice(comma + 1) : url;
|
|
396
|
-
const bytes = base64 ? Uint8Array.from(atob(payload), c => c.charCodeAt(0)) : (new TextEncoder()).encode(decodeURIComponent(payload));
|
|
397
|
-
return new Blob([bytes], { type: mime });
|
|
458
|
+
const normalizedType = String(type).toLowerCase();
|
|
459
|
+
const mime = normalizedType.includes('svg') ? 'image/svg+xml' : /^image\//i.test(normalizedType) ? normalizedType : 'image/png';
|
|
460
|
+
return dataUrlToBlob(url, mime);
|
|
398
461
|
}
|
|
399
462
|
export(options = {}) {
|
|
400
463
|
const rawType = String(options.type || (this.renderer instanceof CanvasRenderer ? 'image/png' : 'image/svg+xml')).toLowerCase();
|
|
@@ -406,9 +469,15 @@ export class Chart {
|
|
|
406
469
|
return 'auto';
|
|
407
470
|
};
|
|
408
471
|
const kind = normalizeType(rawType);
|
|
472
|
+
if (kind === 'auto') return exportError('EXPORT_TYPE_UNSUPPORTED', `Unknown export type: ${rawType}`, 'Use png, svg, jpeg, or json.');
|
|
409
473
|
if (kind === 'json') {
|
|
410
474
|
const payload = { version: '2.0', spec: this.getSpec(), state: this.getState() };
|
|
411
|
-
|
|
475
|
+
const json = JSON.stringify(payload, null, 2);
|
|
476
|
+
if (!options.as || options.as === 'string') return json;
|
|
477
|
+
if (options.as === 'object') return payload;
|
|
478
|
+
if (options.as === 'dataurl') return `data:application/json;charset=utf-8,${encodeURIComponent(json)}`;
|
|
479
|
+
if (options.as === 'blob') return dataUrlToBlob(`data:application/json;charset=utf-8,${encodeURIComponent(json)}`, 'application/json');
|
|
480
|
+
return exportError('EXPORT_TYPE_UNSUPPORTED', `Unsupported JSON export format: ${options.as}`, 'Use string, dataurl, blob, or object.');
|
|
412
481
|
}
|
|
413
482
|
if (kind === 'svg') {
|
|
414
483
|
const svgRendererReady = this.renderer instanceof SVGRenderer && this.renderer.container;
|
|
@@ -457,34 +526,7 @@ export class Chart {
|
|
|
457
526
|
}
|
|
458
527
|
return dataUrl;
|
|
459
528
|
}
|
|
460
|
-
|
|
461
|
-
try {
|
|
462
|
-
const c = require('canvas');
|
|
463
|
-
if (c && c.createCanvas) {
|
|
464
|
-
const cv = c.createCanvas(this.spec.width, this.spec.height);
|
|
465
|
-
const r = new CanvasRenderer({ ...this.spec });
|
|
466
|
-
r.canvas = cv; r.ctx = cv.getContext('2d'); r.resize(this.spec.width, this.spec.height); r.render(this.model.scene);
|
|
467
|
-
const out = r.exportImage(rasterType);
|
|
468
|
-
return options.as === 'blob' ? (typeof Blob !== 'undefined' ? new Blob([Uint8Array.from(atob(out.slice(out.indexOf(',') + 1)), x => x.charCodeAt(0))], { type: rasterType }) : out) : out;
|
|
469
|
-
}
|
|
470
|
-
} catch (_) { /* ignore */ }
|
|
471
|
-
return { valid: false, code: 'HEADLESS_EXPORT_UNSUPPORTED', rasterCode: 'RASTER_EXPORT_UNSUPPORTED', message: `Raster export (${rasterType}) with SVG renderer in headless requires npm i canvas.`, suggestion: 'Install the `canvas` package, or fall back to chart.export({type:"svg"}) which works browser+headless with zero dependencies.' };
|
|
472
|
-
}
|
|
473
|
-
const canvasUnavailable = { valid: false, code: 'HEADLESS_EXPORT_UNSUPPORTED', rasterCode: 'RASTER_EXPORT_UNSUPPORTED', message: `Raster export (${rasterType}) requires a mounted CanvasRenderer with a real canvas element.`, suggestion: 'Mount the chart with { renderer: "canvas", container: "#id" } in a browser, or fall back to chart.export({type:"svg"}) which works browser+headless.' };
|
|
474
|
-
if (typeof document === 'undefined') {
|
|
475
|
-
try {
|
|
476
|
-
const c = require('canvas');
|
|
477
|
-
if (c && c.createCanvas) {
|
|
478
|
-
const cv = c.createCanvas(this.spec.width, this.spec.height);
|
|
479
|
-
const r = new CanvasRenderer({ ...this.spec });
|
|
480
|
-
r.canvas = cv; r.ctx = cv.getContext('2d'); r.resize(this.spec.width, this.spec.height); r.render(this.model.scene);
|
|
481
|
-
const out = r.exportImage(rasterType);
|
|
482
|
-
return options.as === 'blob' ? (typeof Blob !== 'undefined' ? new Blob([Uint8Array.from(atob(out.slice(out.indexOf(',') + 1)), x => x.charCodeAt(0))], { type: rasterType }) : out) : out;
|
|
483
|
-
}
|
|
484
|
-
} catch (_) { /* ignore: continue to structured error */ }
|
|
485
|
-
return { ...canvasUnavailable, headless: true, note: 'In Node headless, run `npm i canvas` and ensure createCanvas() is available, or use chart.export({type:"svg"}).' };
|
|
486
|
-
}
|
|
487
|
-
return canvasUnavailable;
|
|
529
|
+
return exportError('HEADLESS_EXPORT_UNSUPPORTED', `Raster export (${rasterType}) requires a mounted CanvasRenderer or the async optional canvas adapter.`, 'Use chart.exportAsync({ type: "png" }) with the optional canvas package, or fall back to chart.export({ type: "svg" }).', { rasterCode: 'RASTER_EXPORT_UNSUPPORTED' });
|
|
488
530
|
}
|
|
489
531
|
async exportAsync(options = {}) {
|
|
490
532
|
const rawType = String(options.type || (this.renderer instanceof CanvasRenderer ? 'image/png' : 'image/svg+xml')).toLowerCase();
|
|
@@ -496,12 +538,21 @@ export class Chart {
|
|
|
496
538
|
return 'auto';
|
|
497
539
|
};
|
|
498
540
|
const kind = normalizeType(rawType);
|
|
499
|
-
if (kind === '
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
541
|
+
if (kind === 'auto') return exportError('EXPORT_TYPE_UNSUPPORTED', `Unknown export type: ${rawType}`, 'Use png, svg, jpeg, or json.');
|
|
542
|
+
if (kind === 'png' || kind === 'jpeg') {
|
|
543
|
+
const direct = this.export(options);
|
|
544
|
+
if (!(direct && typeof direct === 'object' && direct.code === 'HEADLESS_EXPORT_UNSUPPORTED')) return direct;
|
|
545
|
+
try {
|
|
546
|
+
const moduleName = 'canvas';
|
|
547
|
+
const module = await import(moduleName);
|
|
548
|
+
const createCanvas = canvasFactoryFromModule(module);
|
|
549
|
+
if (!createCanvas) return direct;
|
|
550
|
+
return renderRasterWithCanvas(createCanvas, this.spec, this.model.scene, kind === 'jpeg' ? 'image/jpeg' : 'image/png', options.as);
|
|
551
|
+
} catch {
|
|
552
|
+
return direct;
|
|
553
|
+
}
|
|
503
554
|
}
|
|
504
|
-
return
|
|
555
|
+
return this.export(options);
|
|
505
556
|
}
|
|
506
557
|
_safeFilename(prefix = 'ichart') {
|
|
507
558
|
const sanitize = s => String(s == null ? '' : s).replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || prefix;
|
|
@@ -547,7 +598,7 @@ export class Chart {
|
|
|
547
598
|
return { valid: false, code: 'DOWNLOAD_FAILED', message: String(err && err.message || err), suggestion: 'Try chart.export() and write the result manually.' };
|
|
548
599
|
}
|
|
549
600
|
}
|
|
550
|
-
destroy() { if (this._resizeObserver) this._resizeObserver.disconnect(); this._colorSchemeQuery?.removeEventListener?.('change', this._colorSchemeHandler); if (this._eventsBound) { const { target, handler, start, move, end, leave, touchStart, touchMove, touchEnd, wheel } = this._eventsBound; target.removeEventListener('mousemove', handler); target.removeEventListener('click', handler); target.removeEventListener('pointerdown', start); target.removeEventListener('pointermove', move); target.removeEventListener('pointerup', end); target.removeEventListener('pointercancel', end); target.removeEventListener('pointerleave', leave); target.removeEventListener('touchstart', touchStart); target.removeEventListener('touchmove', touchMove); target.removeEventListener('touchend', touchEnd); target.removeEventListener('wheel', wheel); } const target = this.renderer.svg || this.renderer.canvas; if (this._keyboardHandler) target?.removeEventListener('keydown', this._keyboardHandler); this._tooltip?.remove(); this.plugins.destroy(); this.renderer.destroy(); this.listeners.clear(); this._eventsBound = null; this._selected.clear(); this._clipboard = { nodes: [], edges: [] }; }
|
|
601
|
+
destroy() { this._preferencesUnsubscribe?.(); if (this._resizeObserver) this._resizeObserver.disconnect(); this._colorSchemeQuery?.removeEventListener?.('change', this._colorSchemeHandler); if (this._eventsBound) { const { target, handler, start, move, end, leave, touchStart, touchMove, touchEnd, wheel } = this._eventsBound; target.removeEventListener('mousemove', handler); target.removeEventListener('click', handler); target.removeEventListener('pointerdown', start); target.removeEventListener('pointermove', move); target.removeEventListener('pointerup', end); target.removeEventListener('pointercancel', end); target.removeEventListener('pointerleave', leave); target.removeEventListener('touchstart', touchStart); target.removeEventListener('touchmove', touchMove); target.removeEventListener('touchend', touchEnd); target.removeEventListener('wheel', wheel); } const target = this.renderer.svg || this.renderer.canvas; if (this._keyboardHandler) target?.removeEventListener('keydown', this._keyboardHandler); this._tooltip?.remove(); this.plugins.destroy(); this.renderer.destroy(); this.listeners.clear(); this._eventsBound = null; this._selected.clear(); this._clipboard = { nodes: [], edges: [] }; }
|
|
551
602
|
}
|
|
552
603
|
|
|
553
604
|
export function createChart(spec) { return new Chart(spec); }
|
|
@@ -559,4 +610,5 @@ export { normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies,
|
|
|
559
610
|
export { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };
|
|
560
611
|
|
|
561
612
|
export { contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin };
|
|
562
|
-
export
|
|
613
|
+
export { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, mountChartSettings, normalizePreferences };
|
|
614
|
+
export const iChart = { version: '2.0.6', createChart, inspectData, normalizeData, binData, applyTransforms, normalizeSpec, validateSpec, data, getCapabilities, getChartCapability, planChart, recommend, explainChart, contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, createPreferencesStore, defaultPreferences, normalizePreferences, mergePreferences, 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 };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional, framework-neutral quick settings popover for browser hosts.
|
|
3
|
+
* It stays outside the SVG/Canvas surface and is never part of exports.
|
|
4
|
+
*/
|
|
5
|
+
import { getChartCapability } from './capabilities.mjs';
|
|
6
|
+
import { contrastRatio, themeModes, themePalettes } from './theme.mjs';
|
|
7
|
+
|
|
8
|
+
const styleId = 'ichart-preferences-ui-style';
|
|
9
|
+
const messages = {
|
|
10
|
+
'zh-CN': {
|
|
11
|
+
title: '图表快捷设置', mode: '主题', palette: '配色', scale: '字号', automatic: '自动', defaultSize: '默认', small: '小', large: '大', extraLarge: '特大', legend: '图例', labels: '数据标签', grid: '网格线'
|
|
12
|
+
},
|
|
13
|
+
en: {
|
|
14
|
+
title: 'Chart quick settings', mode: 'Theme', palette: 'Palette', scale: 'Font size', automatic: 'Auto', defaultSize: 'Default', small: 'Small', large: 'Large', extraLarge: 'Extra large', legend: 'Legend', labels: 'Data labels', grid: 'Grid lines'
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function option(label, value) { return `<option value="${value}">${label}</option>`; }
|
|
19
|
+
function safe(value) { return value == null ? '' : String(value); }
|
|
20
|
+
function localeKey(locale) {
|
|
21
|
+
const requested = locale && locale !== 'auto' ? locale : document.documentElement.lang || navigator.language || 'en';
|
|
22
|
+
const normalized = String(requested).toLowerCase();
|
|
23
|
+
if (normalized === 'zh' || normalized.startsWith('zh-')) return 'zh-CN';
|
|
24
|
+
if (normalized === 'en' || normalized.startsWith('en-')) return 'en';
|
|
25
|
+
return 'en';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ensureStyle() {
|
|
29
|
+
if (typeof document === 'undefined' || document.getElementById(styleId)) return;
|
|
30
|
+
const style = document.createElement('style');
|
|
31
|
+
style.id = styleId;
|
|
32
|
+
style.textContent = `.ichart-preferences{position:absolute;top:8px;right:8px;left:8px;height:32px;z-index:20;pointer-events:none;font:12px/1.4 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033}.ichart-preferences__button{position:absolute;top:0;right:0;display:grid;place-items:center;width:32px;height:32px;min-width:32px;min-height:32px;border:0;background:transparent;color:var(--ichart-preferences-icon-color,#172033);border-radius:7px;padding:0;pointer-events:auto;cursor:pointer;font-size:22px;line-height:1;text-shadow:0 1px 2px #0004}.ichart-preferences__panel{display:none;position:absolute;top:38px;right:0;width:min(320px,100%);padding:12px;border:0;border-radius:10px;background:#fff;pointer-events:auto;box-shadow:0 12px 30px #1720332b}.ichart-preferences[data-open="true"] .ichart-preferences__panel{display:block}.ichart-preferences__grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.ichart-preferences__field{display:grid;min-width:0;gap:3px;margin:0}.ichart-preferences__field>span{color:#475467}.ichart-preferences__field select{width:100%;min-width:0;height:32px;border:1px solid #c5cfdb;border-radius:6px;padding:4px 7px;background:#fff;color:inherit}.ichart-preferences__toggles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin-top:8px;padding-top:8px;border-top:1px solid #e2e8f0}.ichart-preferences__toggle{display:flex;align-items:center;justify-content:center;gap:5px;min-height:32px;padding:5px 6px;color:#344054;cursor:pointer}.ichart-preferences__toggle input{width:auto;min-height:auto;margin:0;accent-color:#2563eb}@media(max-width:420px){.ichart-preferences__panel{width:min(288px,100%)}.ichart-preferences__grid,.ichart-preferences__toggles{grid-template-columns:1fr}.ichart-preferences__toggle{justify-content:flex-start}}`;
|
|
33
|
+
document.head.appendChild(style);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function mountChartSettings(chart, { container = chart?.container, locale = 'auto', title } = {}) {
|
|
37
|
+
if (typeof document === 'undefined' || !chart || !container) return { valid: false, code: 'PREFERENCES_UI_UNAVAILABLE', message: 'Chart settings require a browser container.' };
|
|
38
|
+
ensureStyle();
|
|
39
|
+
const text = messages[localeKey(locale)], accessibleTitle = title || text.title, host = container;
|
|
40
|
+
const capability = getChartCapability(chart.getSpec().type), supported = name => capability?.features?.[name] === 'supported';
|
|
41
|
+
const visibleComponents = ['legend', 'labels', 'grid'].filter(supported);
|
|
42
|
+
if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
|
|
43
|
+
const root = document.createElement('div');
|
|
44
|
+
root.className = 'ichart-preferences';
|
|
45
|
+
root.dataset.open = 'false';
|
|
46
|
+
const componentMarkup = visibleComponents.map(name => `<label class="ichart-preferences__toggle"><input type="checkbox" data-key="${name}"><span>${text[name]}</span></label>`).join('');
|
|
47
|
+
root.innerHTML = `<button class="ichart-preferences__button" type="button" aria-expanded="false"><span aria-hidden="true">≡</span></button><div class="ichart-preferences__panel" role="dialog"><div class="ichart-preferences__grid"><label class="ichart-preferences__field"><span>${text.mode}</span><select data-key="mode">${themeModes.map(value => option(value === 'auto' ? text.automatic : value, value)).join('')}</select></label><label class="ichart-preferences__field"><span>${text.palette}</span><select data-key="palette">${themePalettes.map(value => option(value === 'auto' ? text.automatic : value, value)).join('')}</select></label><label class="ichart-preferences__field"><span>${text.scale}</span><select data-key="scale">${option(text.defaultSize, '1')}${option(text.small, '0.85')}${option(text.large, '1.15')}${option(text.extraLarge, '1.3')}</select></label></div>${componentMarkup ? `<div class="ichart-preferences__toggles">${componentMarkup}</div>` : ''}</div>`;
|
|
48
|
+
const button = root.querySelector('.ichart-preferences__button'), panel = root.querySelector('.ichart-preferences__panel');
|
|
49
|
+
button.setAttribute('aria-label', accessibleTitle); button.title = accessibleTitle; panel.setAttribute('aria-label', accessibleTitle);
|
|
50
|
+
host.appendChild(root);
|
|
51
|
+
const values = key => root.querySelector(`[data-key="${key}"]`);
|
|
52
|
+
const readField = key => {
|
|
53
|
+
const control = values(key), value = control?.type === 'checkbox' ? control.checked : control?.value;
|
|
54
|
+
if (['mode', 'palette'].includes(key)) return { theme: { [key]: value === 'auto' ? null : value } };
|
|
55
|
+
if (key === 'scale') return { typography: { scale: Number(value) } };
|
|
56
|
+
if (visibleComponents.includes(key)) return { components: { [key]: Boolean(value) } };
|
|
57
|
+
return {};
|
|
58
|
+
};
|
|
59
|
+
const sync = () => {
|
|
60
|
+
const prefs = chart.getPreferences(), spec = chart.getSpec(), set = (key, value) => { if (values(key)) values(key).value = safe(value); };
|
|
61
|
+
set('mode', prefs.theme?.mode || 'auto'); set('palette', prefs.theme?.palette || 'auto');
|
|
62
|
+
const scaleControl = values('scale'), scale = Number(prefs.typography?.scale || 1), scaleValue = String(scale);
|
|
63
|
+
scaleControl.querySelector('option[data-current]')?.remove();
|
|
64
|
+
if (![...scaleControl.options].some(currentOption => currentOption.value === scaleValue)) { const currentOption = document.createElement('option'); currentOption.value = scaleValue; currentOption.textContent = `${Math.round(scale * 100)}%`; currentOption.dataset.current = 'true'; scaleControl.appendChild(currentOption); }
|
|
65
|
+
scaleControl.value = scaleValue;
|
|
66
|
+
if (values('legend')) values('legend').checked = spec.legend?.visible !== false;
|
|
67
|
+
if (values('labels')) values('labels').checked = Boolean(spec.labels?.enabled);
|
|
68
|
+
if (values('grid')) values('grid').checked = spec.grid?.visible !== false;
|
|
69
|
+
};
|
|
70
|
+
const close = () => { root.dataset.open = 'false'; button.setAttribute('aria-expanded', 'false'); };
|
|
71
|
+
const updateIconColor = () => { const theme = chart.getTheme(), background = chart.getSpec().background || theme.background, darkRatio = contrastRatio('#172033', background), lightRatio = contrastRatio('#ffffff', background), color = darkRatio == null || lightRatio == null ? theme.text || '#172033' : darkRatio >= lightRatio ? '#172033' : '#ffffff'; button.style.setProperty('--ichart-preferences-icon-color', color); };
|
|
72
|
+
const onPreferencesChange = () => { sync(); updateIconColor(); };
|
|
73
|
+
const applyField = key => { try { chart.setPreferences(readField(key), { source: 'ui' }); } catch {} };
|
|
74
|
+
const onToggle = () => { const open = root.dataset.open !== 'true'; root.dataset.open = String(open); button.setAttribute('aria-expanded', String(open)); if (open) sync(); };
|
|
75
|
+
const onDocumentPointer = event => { if (!root.contains(event.target)) close(); };
|
|
76
|
+
const onDocumentKey = event => { if (event.key === 'Escape') { close(); button.focus(); } };
|
|
77
|
+
button.addEventListener('click', onToggle);
|
|
78
|
+
root.querySelectorAll('select,input[data-key]').forEach(control => control.addEventListener('change', () => applyField(control.dataset.key)));
|
|
79
|
+
document.addEventListener('pointerdown', onDocumentPointer);
|
|
80
|
+
document.addEventListener('keydown', onDocumentKey);
|
|
81
|
+
chart.on('preferenceschange', onPreferencesChange);
|
|
82
|
+
chart.on('themechange', updateIconColor);
|
|
83
|
+
sync();
|
|
84
|
+
updateIconColor();
|
|
85
|
+
return { valid: true, element: root, locale: localeKey(locale), destroy() { chart.off('preferenceschange', onPreferencesChange); chart.off('themechange', updateIconColor); document.removeEventListener('pointerdown', onDocumentPointer); document.removeEventListener('keydown', onDocumentKey); root.remove(); } };
|
|
86
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small, renderer-neutral preference contract shared by the Chart API,
|
|
3
|
+
* interactive settings UI, and Agent integrations.
|
|
4
|
+
*/
|
|
5
|
+
import { themeModes, themePalettes, themePresets } from './theme.mjs';
|
|
6
|
+
|
|
7
|
+
export const preferenceVersion = '1.0';
|
|
8
|
+
export const preferenceDensities = ['compact', 'comfortable', 'spacious'];
|
|
9
|
+
export const preferenceTriStates = ['auto', true, false];
|
|
10
|
+
export const preferenceMotions = ['auto', 'full', 'reduced', 'off'];
|
|
11
|
+
|
|
12
|
+
export const defaultPreferences = Object.freeze({
|
|
13
|
+
version: preferenceVersion,
|
|
14
|
+
theme: Object.freeze({ mode: null, preset: null, palette: null }),
|
|
15
|
+
typography: Object.freeze({ scale: 1 }),
|
|
16
|
+
density: 'comfortable',
|
|
17
|
+
components: Object.freeze({ legend: 'auto', labels: 'auto', grid: 'auto' }),
|
|
18
|
+
branding: Object.freeze({ enabled: 'auto' }),
|
|
19
|
+
motion: 'auto'
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function clone(value) { return value == null ? value : JSON.parse(JSON.stringify(value)); }
|
|
23
|
+
|
|
24
|
+
function merge(base, extra) {
|
|
25
|
+
const result = clone(base) || {};
|
|
26
|
+
Object.entries(extra || {}).forEach(([key, value]) => {
|
|
27
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) result[key] = merge(result[key], value);
|
|
28
|
+
else if (value !== undefined) result[key] = clone(value);
|
|
29
|
+
});
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); }
|
|
34
|
+
|
|
35
|
+
function diagnostic(code, path, message, expected, suggestion) {
|
|
36
|
+
return { code, path, message, ...(expected === undefined ? {} : { expected }), ...(suggestion ? { suggestion } : {}) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validatePreferences(input = {}, { partial = true } = {}) {
|
|
40
|
+
const errors = [];
|
|
41
|
+
if (!isPlainObject(input)) return { valid: false, errors: [diagnostic('INVALID_PREFERENCES', '', 'Preferences must be an object.', 'an object', 'Pass a preferences object.')], value: clone(defaultPreferences) };
|
|
42
|
+
const theme = input.theme;
|
|
43
|
+
if (theme !== undefined) {
|
|
44
|
+
if (!isPlainObject(theme)) errors.push(diagnostic('INVALID_PREFERENCE_THEME', 'theme', 'Preferences theme must be an object.', 'an object'));
|
|
45
|
+
else {
|
|
46
|
+
if (theme.mode !== undefined && theme.mode !== null && !themeModes.includes(theme.mode)) errors.push(diagnostic('INVALID_PREFERENCE_THEME_MODE', 'theme.mode', `Unsupported preference theme mode: ${theme.mode}.`, themeModes));
|
|
47
|
+
if (theme.preset !== undefined && theme.preset !== null && !themePresets.includes(theme.preset)) errors.push(diagnostic('INVALID_PREFERENCE_THEME_PRESET', 'theme.preset', `Unsupported preference theme preset: ${theme.preset}.`, themePresets));
|
|
48
|
+
if (theme.palette !== undefined && theme.palette !== null && !themePalettes.includes(theme.palette)) errors.push(diagnostic('INVALID_PREFERENCE_THEME_PALETTE', 'theme.palette', `Unsupported preference theme palette: ${theme.palette}.`, themePalettes));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const scale = input.typography?.scale;
|
|
52
|
+
if (scale !== undefined && (!(Number.isFinite(Number(scale))) || Number(scale) < 0.75 || Number(scale) > 1.5)) errors.push(diagnostic('INVALID_PREFERENCE_SCALE', 'typography.scale', 'Typography scale must be between 0.75 and 1.5.', '0.75..1.5', 'Use 1 for the default size.'));
|
|
53
|
+
if (input.density !== undefined && !preferenceDensities.includes(input.density)) errors.push(diagnostic('INVALID_PREFERENCE_DENSITY', 'density', `Unsupported density: ${input.density}.`, preferenceDensities));
|
|
54
|
+
['legend', 'labels', 'grid'].forEach(name => {
|
|
55
|
+
const value = input.components?.[name];
|
|
56
|
+
if (value !== undefined && !preferenceTriStates.includes(value)) errors.push(diagnostic('INVALID_PREFERENCE_COMPONENT', `components.${name}`, `${name} must be auto, true, or false.`, preferenceTriStates));
|
|
57
|
+
});
|
|
58
|
+
if (input.branding?.enabled !== undefined && !preferenceTriStates.includes(input.branding.enabled)) errors.push(diagnostic('INVALID_PREFERENCE_BRANDING', 'branding.enabled', 'branding.enabled must be auto, true, or false.', preferenceTriStates));
|
|
59
|
+
if (input.motion !== undefined && !preferenceMotions.includes(input.motion)) errors.push(diagnostic('INVALID_PREFERENCE_MOTION', 'motion', `Unsupported motion preference: ${input.motion}.`, preferenceMotions));
|
|
60
|
+
if (!partial && input.version !== undefined && input.version !== preferenceVersion) errors.push(diagnostic('UNSUPPORTED_PREFERENCE_VERSION', 'version', `Unsupported preference version: ${input.version}.`, preferenceVersion));
|
|
61
|
+
return { valid: errors.length === 0, errors, value: normalizePreferences(input, { partial }) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizePreferences(input = {}, { partial = false } = {}) {
|
|
65
|
+
const source = isPlainObject(input) ? input : {};
|
|
66
|
+
const normalized = partial ? {} : clone(defaultPreferences);
|
|
67
|
+
if (source.theme && isPlainObject(source.theme)) {
|
|
68
|
+
normalized.theme = merge(normalized.theme || {}, {
|
|
69
|
+
mode: source.theme.mode === undefined ? (partial ? undefined : null) : source.theme.mode,
|
|
70
|
+
preset: source.theme.preset === undefined ? (partial ? undefined : null) : source.theme.preset,
|
|
71
|
+
palette: source.theme.palette === undefined ? (partial ? undefined : null) : source.theme.palette
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (source.typography && isPlainObject(source.typography) && source.typography.scale !== undefined) normalized.typography = merge(normalized.typography || {}, { scale: Number(source.typography.scale) });
|
|
75
|
+
if (source.density !== undefined) normalized.density = source.density;
|
|
76
|
+
if (source.components && isPlainObject(source.components)) normalized.components = merge(normalized.components || {}, Object.fromEntries(['legend', 'labels', 'grid'].filter(name => source.components[name] !== undefined).map(name => [name, source.components[name]])));
|
|
77
|
+
if (source.branding && isPlainObject(source.branding) && source.branding.enabled !== undefined) normalized.branding = merge(normalized.branding || {}, { enabled: source.branding.enabled });
|
|
78
|
+
if (source.motion !== undefined) normalized.motion = source.motion;
|
|
79
|
+
normalized.version = preferenceVersion;
|
|
80
|
+
return normalized;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function mergePreferences(...values) {
|
|
84
|
+
return normalizePreferences(values.reduce((result, value) => merge(result, normalizePreferences(value, { partial: true })), {}));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveStorage(storage) {
|
|
88
|
+
if (storage && typeof storage.getItem === 'function' && typeof storage.setItem === 'function') return storage;
|
|
89
|
+
if (storage === 'localStorage' && typeof globalThis !== 'undefined' && globalThis.localStorage) return globalThis.localStorage;
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readState(storage, key) {
|
|
94
|
+
if (!storage) return null;
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(storage.getItem(key) || 'null');
|
|
97
|
+
if (!isPlainObject(parsed)) return null;
|
|
98
|
+
return { version: preferenceVersion, global: normalizePreferences(parsed.global), charts: isPlainObject(parsed.charts) ? Object.fromEntries(Object.entries(parsed.charts).map(([id, value]) => [id, normalizePreferences(value, { partial: true })])) : {} };
|
|
99
|
+
} catch { return null; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createPreferencesStore(options = {}) {
|
|
103
|
+
const storage = resolveStorage(options.storage);
|
|
104
|
+
const storageKey = options.storageKey || 'ichartjs:preferences:v1';
|
|
105
|
+
const loaded = readState(storage, storageKey);
|
|
106
|
+
let state = loaded || {
|
|
107
|
+
version: preferenceVersion,
|
|
108
|
+
global: normalizePreferences(options.global || {}),
|
|
109
|
+
charts: Object.fromEntries(Object.entries(options.charts || {}).map(([id, value]) => [id, normalizePreferences(value, { partial: true })]))
|
|
110
|
+
};
|
|
111
|
+
const listeners = new Set();
|
|
112
|
+
const persist = () => {
|
|
113
|
+
if (!storage) return false;
|
|
114
|
+
try { storage.setItem(storageKey, JSON.stringify(state)); return true; } catch { return false; }
|
|
115
|
+
};
|
|
116
|
+
const snapshot = () => clone(state);
|
|
117
|
+
const notify = (scope, chartId, source, persisted) => {
|
|
118
|
+
const payload = { type: 'change', scope, chartId: chartId || null, source: source || 'user', persisted, preferences: chartId ? getEffective(chartId) : clone(state.global), state: snapshot() };
|
|
119
|
+
listeners.forEach(listener => listener(payload));
|
|
120
|
+
};
|
|
121
|
+
const getGlobal = () => clone(state.global);
|
|
122
|
+
const getChart = chartId => clone(state.charts[String(chartId)] || {});
|
|
123
|
+
const getEffective = chartId => mergePreferences(defaultPreferences, state.global, chartId == null ? {} : state.charts[String(chartId)] || {});
|
|
124
|
+
const update = (scope, chartId, patch, { source = 'user', persist: shouldPersist = true } = {}) => {
|
|
125
|
+
const checked = validatePreferences(patch, { partial: true });
|
|
126
|
+
if (!checked.valid) { const error = new Error(checked.errors.map(item => item.message).join(' ')); error.code = 'INVALID_PREFERENCES'; error.details = checked.errors; throw error; }
|
|
127
|
+
if (scope === 'global') state.global = mergePreferences(state.global, patch);
|
|
128
|
+
else {
|
|
129
|
+
const key = String(chartId || 'default');
|
|
130
|
+
state.charts[key] = merge(state.charts[key] || {}, checked.value);
|
|
131
|
+
}
|
|
132
|
+
const persisted = shouldPersist ? persist() : false;
|
|
133
|
+
notify(scope, chartId, source, persisted);
|
|
134
|
+
return scope === 'global' ? getGlobal() : getEffective(chartId);
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
version: preferenceVersion,
|
|
138
|
+
storageKey,
|
|
139
|
+
getState: snapshot,
|
|
140
|
+
getGlobal,
|
|
141
|
+
getChart,
|
|
142
|
+
getEffective,
|
|
143
|
+
setGlobal(patch, options = {}) { return update('global', null, patch, options); },
|
|
144
|
+
setChart(chartId, patch, options = {}) { return update('chart', chartId, patch, options); },
|
|
145
|
+
reset({ scope = 'all', chartId, persist: shouldPersist = true, source = 'user' } = {}) {
|
|
146
|
+
if (scope === 'all' || scope === 'global') state.global = normalizePreferences({});
|
|
147
|
+
if (scope === 'all') state.charts = {};
|
|
148
|
+
if (scope === 'chart' && chartId != null) delete state.charts[String(chartId)];
|
|
149
|
+
const persisted = shouldPersist ? persist() : false;
|
|
150
|
+
notify(scope, chartId, source, persisted);
|
|
151
|
+
return chartId != null ? getEffective(chartId) : getGlobal();
|
|
152
|
+
},
|
|
153
|
+
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
154
|
+
persist,
|
|
155
|
+
storage: storage ? 'localStorage' : 'memory'
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function mergeThemePreference(theme, preferenceTheme = {}) {
|
|
160
|
+
const patch = Object.fromEntries(['mode', 'preset', 'palette'].filter(key => preferenceTheme?.[key] != null).map(key => [key, preferenceTheme[key]]));
|
|
161
|
+
if (!Object.keys(patch).length) return theme;
|
|
162
|
+
if (typeof theme === 'string') {
|
|
163
|
+
const base = themeModes.includes(theme) ? { mode: theme } : themePresets.includes(theme) ? { preset: theme } : {};
|
|
164
|
+
return { ...base, ...patch };
|
|
165
|
+
}
|
|
166
|
+
return { ...(isPlainObject(theme) ? theme : {}), ...patch };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function scaledFont(font, scale) {
|
|
170
|
+
return String(font || '').replace(/(\d+(?:\.\d+)?)px/, (_, value) => `${Number((Number(value) * scale).toFixed(2))}px`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function applyPreferencesToSpec(spec, preferences = {}) {
|
|
174
|
+
const result = clone(spec) || {};
|
|
175
|
+
const prefs = mergePreferences(preferences);
|
|
176
|
+
result.theme = clone(result.theme) || {};
|
|
177
|
+
const scale = prefs.typography.scale;
|
|
178
|
+
if (scale !== 1 && result.theme.typography) {
|
|
179
|
+
result.theme.typography = Object.fromEntries(Object.entries(result.theme.typography).map(([key, value]) => [key, { ...value, size: Number((Number(value.size) * scale).toFixed(2)), font: scaledFont(value.font, scale) }]));
|
|
180
|
+
}
|
|
181
|
+
if (prefs.density !== 'comfortable') {
|
|
182
|
+
const factor = prefs.density === 'compact' ? 0.82 : 1.2;
|
|
183
|
+
const padding = result.theme.layout?.padding || result.padding;
|
|
184
|
+
if (padding) result.padding = Object.fromEntries(Object.entries(padding).map(([key, value]) => [key, Math.max(0, Math.round(Number(value) * factor))]));
|
|
185
|
+
if (result.theme.layout) result.theme.layout = { ...result.theme.layout, density: prefs.density, spacing: Number((Number(result.theme.layout.spacing || 8) * factor).toFixed(2)) };
|
|
186
|
+
}
|
|
187
|
+
const component = (path, value) => value === 'auto' ? undefined : value;
|
|
188
|
+
const legend = component('legend', prefs.components.legend), labels = component('labels', prefs.components.labels), grid = component('grid', prefs.components.grid);
|
|
189
|
+
if (legend !== undefined) result.legend = { ...(result.legend || {}), visible: legend };
|
|
190
|
+
if (labels !== undefined) result.labels = { ...(result.labels || {}), enabled: labels };
|
|
191
|
+
if (grid !== undefined) result.grid = { ...(result.grid || {}), visible: grid };
|
|
192
|
+
if (prefs.branding.enabled !== 'auto') result.branding = { ...(result.branding || {}), enabled: prefs.branding.enabled };
|
|
193
|
+
return result;
|
|
194
|
+
}
|