@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/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 (node.type === 'path' && !s.fill) attrs.push(`fill="none"`);
118
- else if (s.fill) attrs.push(`fill="${_svgEscape(s.fill, 'attr')}"`);
119
- if (s.stroke) attrs.push(`stroke="${_svgEscape(s.stroke, 'attr')}"`);
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 result = validateSpec(input);
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.theme = resolveTheme(this._themeInput, 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 (next.theme !== undefined) this._themeInput = next.theme; ['colors', 'background', 'padding'].forEach(key => { if (next[key] !== undefined) this._styleOverrides[key] = true; }); this.spec = normalizeSpec({ ...this.spec, ...next, theme: this._themeInput, data: next.data === undefined ? this.spec.data : next.data }); this._resolveStyle(); return this.render(); }
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 ? sceneToSvgString(this.model.scene, this.spec) : this.renderer.exportString();
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 { valid: false, code: 'EXPORT_UNSUPPORTED', message: `toDataURL('${type}') is not supported with renderer ${this.renderer?.constructor?.name || 'unknown'}.`, suggestion: 'Mount the chart to a DOM container, or switch renderer=canvas for PNG export.' };
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
- const url = this.toDataURL(type);
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 mime = type.includes('svg') ? 'image/svg+xml' : /^image\//i.test(type) ? type : 'image/png';
393
- const comma = url.indexOf(',');
394
- const base64 = comma >= 0 && /;base64$/i.test(url.slice(0, comma));
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
- return options.as === 'object' ? payload : JSON.stringify(payload, null, 2);
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
- if (this.renderer instanceof SVGRenderer) {
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 === 'json' || kind === 'svg' || kind === 'png' || kind === 'jpeg') {
500
- const out = this.export(options);
501
- if (out && typeof out === 'object' && out.valid === false) return Promise.reject(out);
502
- return Promise.resolve(out);
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 Promise.reject({ valid: false, code: 'EXPORT_TYPE_UNSUPPORTED', message: `Unknown export type: ${rawType}`, suggestion: 'Use png, svg, jpeg, or json.' });
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 const iChart = { version: '2.0.4', createChart, inspectData, normalizeData, binData, applyTransforms, normalizeSpec, validateSpec, data, getCapabilities, getChartCapability, planChart, recommend, explainChart, contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, 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 };
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
+ }