@taylorwong/ichartjs 2.0.5 → 2.0.7

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +11 -4
  3. package/docs/agent/README.md +3 -1
  4. package/docs/agent/coding-agent-integration.md +6 -2
  5. package/docs/agent/development/iteration-11.md +44 -0
  6. package/docs/agent/development/iteration-12.md +101 -0
  7. package/docs/agent/development/roadmap.md +29 -3
  8. package/docs/agent/development-guide.md +1 -1
  9. package/docs/agent/diagram-scenario.md +39 -11
  10. package/docs/agent/editing-contract.md +5 -0
  11. package/docs/agent/frontend-integration.md +1 -1
  12. package/docs/agent/runtime-contract.md +11 -1
  13. package/docs/agent/theme-guide.md +63 -0
  14. package/docs/agent/usage-scenarios.md +40 -1
  15. package/docs/agent/zh-CN/README.md +1 -1
  16. package/docs/agent/zh-CN/coding-agent-integration.md +6 -2
  17. package/docs/agent/zh-CN/diagram-scenario.md +32 -3
  18. package/docs/agent/zh-CN/editing-contract.md +2 -0
  19. package/docs/agent/zh-CN/frontend-integration.md +1 -1
  20. package/docs/agent/zh-CN/iteration-12.md +98 -0
  21. package/docs/agent/zh-CN/runtime-contract.md +3 -1
  22. package/docs/agent/zh-CN/theme-guide.md +63 -0
  23. package/docs/agent/zh-CN/usage-scenarios.md +40 -1
  24. package/docs/manifests/capabilities.json +37 -2
  25. package/docs/manifests/commands.json +10 -9
  26. package/docs/manifests/schemas.json +5 -2
  27. package/package.json +1 -1
  28. package/skills/ichartjs/SKILL.md +11 -2
  29. package/src/capabilities.mjs +65 -8
  30. package/src/charts.mjs +127 -44
  31. package/src/command.mjs +4 -3
  32. package/src/diagram-interaction.mjs +55 -11
  33. package/src/diagram.mjs +149 -36
  34. package/src/edit-controller.mjs +6 -3
  35. package/src/edit.mjs +17 -7
  36. package/src/index.mjs +68 -17
  37. package/src/layout.mjs +39 -0
  38. package/src/preferences-ui.mjs +135 -0
  39. package/src/preferences.mjs +194 -0
  40. package/src/project.mjs +64 -24
  41. package/src/renderer.mjs +2 -2
  42. package/src/scene.mjs +32 -1
  43. package/src/schema.mjs +7 -2
  44. package/src/spec.mjs +10 -4
  45. package/src/theme.mjs +1 -1
  46. package/types/index.d.ts +39 -3
package/src/index.mjs CHANGED
@@ -16,15 +16,18 @@ import { commitPreview, getEditCapabilities, previewEdit, validateEdit } from '.
16
16
  import { EditHistory } from './history.mjs';
17
17
  import { validateRecipe } from './recipes.mjs';
18
18
  import { EditController } from './edit-controller.mjs';
19
- import { diagramLayoutModes, edgeRoutingModes, normalizeDiagramSpec, validateDiagram, layoutDiagram, routeEdge } from './diagram.mjs';
19
+ import { diagramLayoutModes, diagramModes, edgeRoutingModes, normalizeDiagramSpec, validateDiagram, layoutDiagram, routeEdge } from './diagram.mjs';
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
- import { explainChart, getCapabilities as discoverCapabilities, getChartCapability, planChart } from './capabilities.mjs';
22
+ import { explainChart, getCapabilities as discoverCapabilities, getChartCapability, getPreferenceCapabilities, planChart } from './capabilities.mjs';
23
+ import { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, normalizePreferences, validatePreferences } 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 };
@@ -98,10 +101,13 @@ function sceneToSvgString(scene, spec = {}) {
98
101
  }
99
102
  attrs.push(`d="${_svgEscape(d, 'attr')}"`);
100
103
  } else if (node.type === 'path') {
101
- const d = `${(g.points || []).map((p, i) => `${i ? 'L' : 'M'} ${p.x} ${p.y}`).join(' ')}${g.closed ? ' Z' : ''}`;
104
+ const d = g.curve === 'cubic' && g.points?.length === 4
105
+ ? `M ${g.points[0].x} ${g.points[0].y} C ${g.points[1].x} ${g.points[1].y} ${g.points[2].x} ${g.points[2].y} ${g.points[3].x} ${g.points[3].y}`
106
+ : `${(g.points || []).map((p, i) => `${i ? 'L' : 'M'} ${p.x} ${p.y}`).join(' ')}${g.closed ? ' Z' : ''}`;
102
107
  attrs.push(`d="${_svgEscape(d, 'attr')}"`);
103
108
  } else if (node.type === 'text') {
104
109
  attrs.push(`x="${g.x}" y="${g.y}"`);
110
+ if (Number.isFinite(Number(s.rotation)) && Number(s.rotation) !== 0) attrs.push(`transform="rotate(${Number(s.rotation)} ${g.x} ${g.y})"`);
105
111
  const anchor = s.textAnchor || (s.textAlign === 'end' ? 'end' : s.textAlign === 'center' ? 'middle' : s.textAlign === 'right' ? 'end' : s.textAlign === 'left' ? 'start' : 'start');
106
112
  if (anchor) attrs.push(`text-anchor="${anchor}"`);
107
113
  const baseline = s.textBaseline || s.baseline || 'alphabetic';
@@ -178,12 +184,23 @@ function renderRasterWithCanvas(createCanvas, spec, scene, rasterType, as) {
178
184
 
179
185
  export class Chart {
180
186
  constructor(input = {}) {
181
- const result = validateSpec(input);
187
+ const specInput = { ...input };
188
+ delete specInput.preferences;
189
+ delete specInput.preferencesStore;
190
+ delete specInput.chartId;
191
+ const result = validateSpec(specInput);
182
192
  if (!result.valid) { const error = new Error(result.errors.map(item => item.message).join(' ')); error.details = result.errors; throw error; }
183
193
  this._specDiagnostics = { warnings: result.warnings, normalizations: result.normalizations };
194
+ this.chartId = input.chartId ?? input.id ?? null;
195
+ this._preferencesStore = isPreferencesStore(input.preferencesStore) ? input.preferencesStore : isPreferencesStore(input.preferences) ? input.preferences : null;
196
+ this._localPreferences = this._preferencesStore ? null : normalizePreferences(input.preferences || {});
197
+ delete result.spec.preferences;
198
+ delete result.spec.preferencesStore;
199
+ delete result.spec.chartId;
184
200
  this._themeInput = input.theme ?? 'auto';
185
- this._styleOverrides = { colors: input.colors !== undefined, background: input.background !== undefined, padding: input.padding !== undefined };
201
+ this._styleOverrides = { colors: input.colors !== undefined, background: input.background !== undefined, padding: input.padding !== undefined, legend: false, labels: false, grid: false, branding: false };
186
202
  this.spec = result.spec;
203
+ this._preferenceBase = Object.fromEntries(['legend', 'labels', 'grid', 'branding', 'padding'].map(key => [key, clone(this.spec[key])]));
187
204
  this._resolveStyle();
188
205
  this.container = typeof document === 'undefined' ? null : resolveContainer(this.spec.container);
189
206
  this.listeners = new Map();
@@ -198,12 +215,27 @@ export class Chart {
198
215
  this._observeResize();
199
216
  this._observeColorScheme();
200
217
  this.render();
218
+ this._preferencesSnapshot = JSON.stringify(this.getPreferences());
219
+ if (this._preferencesStore) this._preferencesUnsubscribe = this._preferencesStore.subscribe(event => {
220
+ const next = this._preferencesStore.getEffective(this.chartId);
221
+ const serialized = JSON.stringify(next);
222
+ if (serialized === this._preferencesSnapshot) return;
223
+ this._preferencesSnapshot = serialized;
224
+ this._resolveStyle();
225
+ this.render();
226
+ this.emit('preferenceschange', { chart: this, preferences: clone(next), source: event.source, scope: event.scope, persisted: event.persisted });
227
+ });
201
228
  }
202
229
  _resolveStyle() {
203
- this.spec.theme = resolveTheme(this._themeInput, this.spec);
230
+ ['legend', 'labels', 'grid', 'branding'].forEach(key => { this.spec[key] = clone(this._preferenceBase[key]); });
231
+ if (this._styleOverrides.padding) this.spec.padding = clone(this._preferenceBase.padding);
232
+ const preferences = this.getPreferences();
233
+ this.spec.theme = resolveTheme(mergeThemePreference(this._themeInput, preferences.theme), this.spec);
204
234
  if (!this._styleOverrides.colors) this.spec.colors = [...this.spec.theme.colors];
205
235
  if (!this._styleOverrides.background) this.spec.background = this.spec.theme.background;
206
236
  if (!this._styleOverrides.padding) this.spec.padding = clone(this.spec.theme.layout.padding);
237
+ this.spec = applyPreferencesToSpec(this.spec, preferences);
238
+ ['legend', 'labels', 'grid', 'branding'].forEach(key => { if (this._styleOverrides[key]) this.spec[key] = clone(this._preferenceBase[key]); });
207
239
  }
208
240
  _observeColorScheme() {
209
241
  if (typeof matchMedia !== 'function') return;
@@ -237,9 +269,13 @@ export class Chart {
237
269
  element.setAttribute('role', 'group');
238
270
  element.setAttribute('aria-label', `Group ${dataRef.groupId}${dataRef.collapsed ? ', collapsed' : ', expanded'}`);
239
271
  element.setAttribute('aria-expanded', String(!dataRef.collapsed));
272
+ } else if (dataRef.edgeHandle) {
273
+ element.setAttribute('role', 'button');
274
+ element.setAttribute('aria-label', `${dataRef.edgeHandle === 'segment' ? 'Segment' : 'Waypoint'} handle for edge ${dataRef.edgeId}`);
240
275
  } else if (dataRef.edgeId || dataRef.from && dataRef.to) {
241
276
  element.setAttribute('role', 'img');
242
277
  element.setAttribute('aria-label', `Edge from ${dataRef.from} to ${dataRef.to}`);
278
+ element.setAttribute('aria-selected', String(this.getSelectedEdgeIds().includes(dataRef.edgeId)));
243
279
  if (dataRef.from) element.setAttribute('aria-describedby', `node-${dataRef.from}`);
244
280
  } else if (dataRef.nodeId && !dataRef.portId) {
245
281
  element.setAttribute('role', 'button');
@@ -260,7 +296,7 @@ export class Chart {
260
296
  const ids = [];
261
297
  this.model.scene.walk(node => { if (node.interactive && node.dataRef) ids.push(node.id); });
262
298
  if (!ids.length) return;
263
- if (['flow', 'swimlane'].includes(this.spec.type) && this.spec.editing?.enabled && this._selected.size) {
299
+ if (isDiagram(this) && this.spec.editing?.enabled && this._selected.size) {
264
300
  const delta = { x: ['ArrowRight', 'ArrowLeft'].includes(event.key) ? (event.key === 'ArrowRight' ? 8 : -8) : 0, y: ['ArrowDown', 'ArrowUp'].includes(event.key) ? (event.key === 'ArrowDown' ? 8 : -8) : 0 };
265
301
  const result = this.moveSelectedBy(delta, { confirmed: true, source: 'keyboard' });
266
302
  if (result.valid) event.preventDefault();
@@ -290,22 +326,25 @@ export class Chart {
290
326
  const touchEnd = () => { this._touchStart = null; this._pinchStart = null; };
291
327
  const wheel = event => { if (!this.spec.interaction.zoom) return; event.preventDefault(); if (isDiagram(this)) { this.zoomTo({ scale: Math.max(0.25, Math.min(8, (this.spec.view?.scale || 1) * (event.deltaY > 0 ? 0.9 : 1.1))) }); return; } const count = normalizeData(this.spec.data).rows.length, current = this.spec.view || { start: 0, end: count }; this.zoomTo(resolveZoomWindow(count, current, event.deltaY > 0 ? 1.25 : 0.8)); };
292
328
  const leave = event => { this._hideTooltip(); if (!this._dragStart && !this._nodeDrag) end(event); };
293
- if (isDiagram(this)) target.style.touchAction = 'none'; target.addEventListener('mousemove', handler); target.addEventListener('click', handler); target.addEventListener('pointerdown', start); target.addEventListener('pointermove', move); target.addEventListener('pointerup', end); target.addEventListener('pointercancel', end); target.addEventListener('pointerleave', leave); target.addEventListener('touchstart', touchStart, { passive: false }); target.addEventListener('touchmove', touchMove, { passive: false }); target.addEventListener('touchend', touchEnd); target.addEventListener('wheel', wheel, { passive: false }); this._eventsBound = { target, handler, start, move, end, leave, touchStart, touchMove, touchEnd, wheel }; }
329
+ if (isDiagram(this)) target.style.touchAction = ['zoom', 'pan', 'brush', 'drag', 'edgeDrag', 'portConnect'].some(name => this.spec.interaction?.[name]) ? 'none' : 'auto'; target.addEventListener('mousemove', handler); target.addEventListener('click', handler); target.addEventListener('pointerdown', start); target.addEventListener('pointermove', move); target.addEventListener('pointerup', end); target.addEventListener('pointercancel', end); target.addEventListener('pointerleave', leave); target.addEventListener('touchstart', touchStart, { passive: false }); target.addEventListener('touchmove', touchMove, { passive: false }); target.addEventListener('touchend', touchEnd); target.addEventListener('wheel', wheel, { passive: false }); this._eventsBound = { target, handler, start, move, end, leave, touchStart, touchMove, touchEnd, wheel }; }
294
330
  _touchDistance(event) { if (!event.touches || event.touches.length < 2) return null; const [first, second] = event.touches; return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY); }
295
331
  _eventPoint(event, target) { const rect = target.getBoundingClientRect(); return { x: (event.clientX - rect.left) * this.spec.width / (rect.width || this.spec.width), y: (event.clientY - rect.top) * this.spec.height / (rect.height || this.spec.height) }; }
296
332
  _updateCrosshair(x, y) { const vertical = this.model.scene.find('crosshair-x'), horizontal = this.model.scene.find('crosshair-y'); if (!vertical || !horizontal) return; vertical.geometry.x1 = vertical.geometry.x2 = x; vertical.style.opacity = 0.7; horizontal.geometry.y1 = horizontal.geometry.y2 = y; horizontal.style.opacity = 0.7; this.renderer.render(this.model.scene); }
297
- _showTooltip(payload) { if (typeof document === 'undefined' || !this.container || !payload.datum) return; let tip = this._tooltip; if (!tip) { tip = this._tooltip = document.createElement('div'); tip.className = 'ichart-v2-tooltip'; Object.assign(tip.style, { position: 'fixed', pointerEvents: 'none', zIndex: 9999, padding: '8px 10px', borderRadius: '6px', maxWidth: 'min(320px, calc(100vw - 24px))', whiteSpace: 'pre-line' }); document.body.appendChild(tip); } Object.assign(tip.style, { background: this.spec.theme.surface, color: this.spec.theme.text, border: `1px solid ${this.spec.theme.border}`, font: this.spec.theme.typography.tooltip.font, boxShadow: `0 4px 12px ${this.spec.theme.border}88` }); const text = ['gantt', 'timeline', 'milestone', 'burndown'].includes(this.spec.type) || ['flow', 'swimlane'].includes(this.spec.type) ? projectTooltip(this.spec.type, payload.datum) : Object.entries(payload.datum).map(([key, value]) => `${key}: ${value}`).join(' · '); tip.textContent = text; const left = Math.min((payload.nativeEvent.clientX || 0) + 12, window.innerWidth - tip.offsetWidth - 12); const top = Math.min((payload.nativeEvent.clientY || 0) + 12, window.innerHeight - tip.offsetHeight - 12); tip.style.left = `${Math.max(12, left)}px`; tip.style.top = `${Math.max(12, top)}px`; }
333
+ _showTooltip(payload) { if (typeof document === 'undefined' || !this.container || !payload.datum) return; let tip = this._tooltip; if (!tip) { tip = this._tooltip = document.createElement('div'); tip.className = 'ichart-v2-tooltip'; Object.assign(tip.style, { position: 'fixed', pointerEvents: 'none', zIndex: 9999, padding: '8px 10px', borderRadius: '6px', maxWidth: 'min(320px, calc(100vw - 24px))', whiteSpace: 'pre-line' }); document.body.appendChild(tip); } Object.assign(tip.style, { background: this.spec.theme.surface, color: this.spec.theme.text, border: `1px solid ${this.spec.theme.border}`, font: this.spec.theme.typography.tooltip.font, boxShadow: `0 4px 12px ${this.spec.theme.border}88` }); const text = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(this.spec.type) ? projectTooltip(this.spec.type, payload.datum) : Object.entries(payload.datum).map(([key, value]) => `${key}: ${value}`).join(' · '); tip.textContent = text; const left = Math.min((payload.nativeEvent.clientX || 0) + 12, window.innerWidth - tip.offsetWidth - 12); const top = Math.min((payload.nativeEvent.clientY || 0) + 12, window.innerHeight - tip.offsetHeight - 12); tip.style.left = `${Math.max(12, left)}px`; tip.style.top = `${Math.max(12, top)}px`; }
298
334
  _hideTooltip() { if (this._tooltip) this._tooltip.style.left = '-10000px'; }
299
335
  on(type, listener) { if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type).add(listener); return this; }
300
336
  off(type, listener) { this.listeners.get(type)?.delete(listener); return this; }
301
337
  emit(type, event) { this.listeners.get(type)?.forEach(listener => listener(event)); }
302
- 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(); }
338
+ 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', 'legend', 'labels', 'grid', 'branding'].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(); }
303
339
  setData(data) { this._editor.invalidate(); this.spec = normalizeSpec({ ...this.spec, theme: this._themeInput, data: { values: data } }); this._resolveStyle(); return this.render(); }
304
340
  setTheme(theme = 'auto') { this._themeInput = theme; this._resolveStyle(); this.render(); this.emit('themechange', { chart: this, theme: this.getTheme(), source: 'user' }); return this; }
305
341
  getTheme() { return clone(this.spec.theme); }
342
+ getPreferences() { return this._preferencesStore ? this._preferencesStore.getEffective(this.chartId) : clone(this._localPreferences || defaultPreferences); }
343
+ 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; }
344
+ 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; }
306
345
  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; }
307
346
  getSpec() { return JSON.parse(JSON.stringify(this.spec)); }
308
- 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) }; }
347
+ 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) }; }
309
348
  getProjectAnalytics() { return clone(this.model.state?.projectAnalytics || null); }
310
349
  getLinkedState() { return clone(this.model.state?.linked || null); }
311
350
  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(); }
@@ -325,8 +364,10 @@ export class Chart {
325
364
  highlight(target) { if (target?.id) target.highlighted = true; this.render(); return this; }
326
365
  select(target, options = {}) { if (!options.additive) { this._selected.clear(); this.model?.scene.walk(node => { node.selected = false; }); } if (target?.id) { target.selected = true; this._selected.set(target.id, target.dataRef); } this.emit('selectionchange', { chart: this, target, selectedData: this.getSelectedData() }); return this; }
327
366
  selectNodes(nodeIds = [], options = {}) { if (!options.additive) { this._selected.clear(); this.model.scene.walk(node => { node.selected = false; }); } nodeIds.forEach(id => { const node = this.model.scene.find(`node-${id}`); if (node) { node.selected = true; this._selected.set(node.id, node.dataRef); } }); this.emit('selectionchange', { chart: this, target: null, selectedData: this.getSelectedData() }); this.render(); return this; }
367
+ selectEdges(edgeIds = [], options = {}) { if (!options.additive) { this._selected.clear(); this.model.scene.walk(node => { node.selected = false; }); } const selected = new Set(edgeIds); this.model.scene.walk(node => { if (node.dataRef?.edgeId && !node.dataRef?.edgeHandle && selected.has(node.dataRef.edgeId)) { node.selected = true; this._selected.set(node.id, node.dataRef); } }); this.emit('selectionchange', { chart: this, target: null, selectedData: this.getSelectedData() }); this.render(); return this; }
328
368
  selectGroup(groupId, options = {}) { return this.selectNodes(this.model.data.rows.filter(row => row.groupId === groupId).map(row => row.id), options); }
329
369
  getSelectedNodeIds() { return [...new Set([...this._selected.values()].map(ref => ref?.nodeId).filter(Boolean))]; }
370
+ getSelectedEdgeIds() { return [...new Set([...this._selected.values()].map(ref => ref?.edgeId).filter(Boolean))]; }
330
371
  getDiagramNodes() { return (this.spec.nodes || this.spec.data?.nodes || []).map(node => ({ ...node })); }
331
372
  getDiagramEdges() { return (this.spec.edges || this.spec.data?.edges || []).map(edge => ({ ...edge })); }
332
373
  getDiagramGroups() { return (this.spec.groups || this.spec.data?.groups || []).map(group => ({ ...group })); }
@@ -369,6 +410,15 @@ export class Chart {
369
410
  const preview = this.previewEdit({ type: 'layout-edit', reason: 'Connect diagram nodes', operations: [operation] });
370
411
  return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed });
371
412
  }
413
+ deleteSelectedEdges(options = {}) {
414
+ const edgeIds = this.getSelectedEdgeIds();
415
+ if (!edgeIds.length) return { valid: false, errors: [{ code: 'NO_SELECTION', message: 'Select one or more diagram edges first.' }] };
416
+ if (this.spec.editing?.allowDelete !== true) return { valid: false, errors: [{ code: 'DELETE_DISABLED', message: 'Edge deletion requires editing.allowDelete.' }] };
417
+ const preview = this.previewEdit({ type: 'layout-edit', reason: 'Delete selected diagram edges', operations: edgeIds.map(edgeId => ({ op: 'removeEdge', edgeId })) });
418
+ const result = this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed });
419
+ if (result.valid) this.clearSelection();
420
+ return result;
421
+ }
372
422
  toggleGroupCollapse(groupId, options = {}) {
373
423
  const preview = this.previewEdit({ type: 'layout-edit', reason: 'Toggle group collapse', operations: [{ op: 'toggleGroupCollapse', groupId, ...(options.collapsed !== undefined ? { collapsed: options.collapsed } : {}) }] });
374
424
  const result = this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed });
@@ -383,7 +433,7 @@ export class Chart {
383
433
  moveSelectedBy(delta, options = {}) { const nodeIds = this.getSelectedNodeIds(); if (!nodeIds.length) return { valid: false, errors: [{ code: 'NO_SELECTION', message: 'Select one or more diagram nodes first.' }] }; const preview = this.previewEdit({ type: 'layout-edit', reason: options.reason || 'Move selected nodes', operations: [{ op: 'moveNodes', nodeIds, delta }] }); return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed }); }
384
434
  alignSelected(alignment, options = {}) { const nodeIds = this.getSelectedNodeIds(); const preview = this.previewEdit({ type: 'layout-edit', reason: `Align selected nodes ${alignment}`, operations: [{ op: 'alignNodes', nodeIds, alignment }] }); return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed }); }
385
435
  snapSelected(options = {}) { const nodeIds = this.getSelectedNodeIds(); const preview = this.previewEdit({ type: 'layout-edit', reason: 'Snap selected nodes', operations: [{ op: 'snapNodes', nodeIds }] }); return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed }); }
386
- getSelectedData() { const diagram = ['flow', 'swimlane'].includes(this.spec.type), source = diagram ? (this.spec.nodes || this.spec.data?.nodes || []) : normalizeData(this.spec.data).rows, offset = diagram ? 0 : (this.model.data.viewOffset || 0); return [...this._selected.values()].map(ref => diagram ? source.find(row => row.id === ref?.nodeId) : (ref?.dataIndex == null ? ref?.datum : source[ref.dataIndex + offset])).filter(Boolean); }
436
+ getSelectedData() { const diagram = isDiagram(this), source = diagram ? (this.spec.nodes || this.spec.data?.nodes || []) : normalizeData(this.spec.data).rows, edges = diagram ? (this.spec.edges || this.spec.data?.edges || []) : [], offset = diagram ? 0 : (this.model.data.viewOffset || 0); return [...this._selected.values()].map(ref => diagram ? ref?.edgeId ? edges.find((row, index) => (row.id || `edge-${index}`) === ref.edgeId) : source.find(row => row.id === ref?.nodeId) : (ref?.dataIndex == null ? ref?.datum : source[ref.dataIndex + offset])).filter(Boolean); }
387
437
  clearSelection() { this._selected.clear(); this.render(); this.emit('selectionchange', { chart: this, target: null, selectedData: [] }); return this; }
388
438
  getElementAt(x, y) { return this.model.scene.hit(x, y); }
389
439
  selectBox(start, end) { const left = Math.min(start.x, end.x), right = Math.max(start.x, end.x), top = Math.min(start.y, end.y), bottom = Math.max(start.y, end.y); this._selected.clear(); this.model.scene.walk(node => { node.selected = false; if (!node.dataRef || !node.bounds || !node.interactive || (isDiagram(this) && !node.id.startsWith('node-'))) return; const intersects = node.bounds.x <= right && node.bounds.x + node.bounds.width >= left && node.bounds.y <= bottom && node.bounds.y + node.bounds.height >= top; if (intersects) { node.selected = true; this._selected.set(node.id, node.dataRef); } }); this.emit('selectionchange', { chart: this, selectedData: this.getSelectedData(), box: { start, end } }); this.render(); return this; }
@@ -567,16 +617,17 @@ export class Chart {
567
617
  return { valid: false, code: 'DOWNLOAD_FAILED', message: String(err && err.message || err), suggestion: 'Try chart.export() and write the result manually.' };
568
618
  }
569
619
  }
570
- 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: [] }; }
620
+ 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: [] }; }
571
621
  }
572
622
 
573
623
  export function createChart(spec) { return new Chart(spec); }
574
- export function getCapabilities() { return { ...discoverCapabilities(), diagram: { layoutModes: diagramLayoutModes, routingModes: edgeRoutingModes, entities: ['node', 'edge', 'lane', 'group', 'port'], operations: ['moveNode', 'moveNodes', 'resizeNode', 'alignNodes', 'snapNodes', 'moveNodeToLane', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'], validation: ['duplicate-ids', 'missing-endpoints', 'missing-ports', 'missing-lanes'] }, editing: { modes: ['preview', 'commit', 'undo', 'redo'], operations: ['updateField', 'updateRecord', 'updateTask', 'shiftTask', 'updateProgress', 'addDependency', 'removeDependency', 'updateMilestone', 'moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'], confirmationRequiredByDefault: true, commit: 'local-runtime-host-persistence-required' } }; }
624
+ export function getCapabilities() { return { ...discoverCapabilities(), interactionDefaults: { zoom: false, pan: false, brush: false, drag: false, edgeDrag: false, portConnect: false, editing: false }, diagram: { layoutModes: diagramLayoutModes, routingModes: edgeRoutingModes, entities: ['node', 'edge', 'lane', 'group', 'port', 'waypoint'], operations: ['moveNode', 'moveNodes', 'resizeNode', 'alignNodes', 'snapNodes', 'moveNodeToLane', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'removeEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'], curvedEdges: { renderers: ['canvas', 'svg'], type: 'cubic-bezier', tension: { minimum: 0.2, maximum: 0.8, default: 0.4 }, mindmapDefault: true, obstacleFallback: 'orthogonal', controlPointEditing: false }, edgeEditing: { renderers: ['canvas', 'svg'], selection: true, waypointDrag: true, segmentDrag: true, persistentWaypoints: true, enabledByDefault: false, activation: ['editing.enabled', 'interaction.edgeDrag'] }, validation: ['duplicate-ids', 'missing-endpoints', 'missing-ports', 'missing-lanes', 'invalid-waypoints', 'invalid-curve-tension'] }, editing: { modes: ['preview', 'commit', 'undo', 'redo'], operations: ['updateField', 'updateRecord', 'updateTask', 'shiftTask', 'updateProgress', 'addDependency', 'removeDependency', 'updateMilestone', 'moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'removeEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'], confirmationRequiredByDefault: true, commit: 'local-runtime-host-persistence-required' } }; }
575
625
  export function recommend(input, options = {}) { const plan = planChart(input, options); return { primary: plan.primary, alternatives: plan.alternatives, reason: plan.reasons.join(' '), reasons: plan.reasons, confidence: plan.confidence, requiredFields: plan.requiredFields, assumptions: plan.assumptions, warnings: plan.warnings, nextActions: plan.nextActions }; }
576
- export { normalizeSpec, validateSpec, normalizeData, inspectData, binData, applyTransforms, data, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, getChartCapability, planChart, explainChart };
577
- export { diagramLayoutModes, edgeRoutingModes, normalizeDiagramSpec, validateDiagram, layoutDiagram, routeEdge };
626
+ export { normalizeSpec, validateSpec, normalizeData, inspectData, binData, applyTransforms, data, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, getChartCapability, getPreferenceCapabilities, planChart, explainChart };
627
+ export { diagramLayoutModes, diagramModes, edgeRoutingModes, normalizeDiagramSpec, validateDiagram, layoutDiagram, routeEdge };
578
628
  export { normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries };
579
629
  export { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };
580
630
 
581
631
  export { contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin };
582
- export const iChart = { version: '2.0.5', 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 };
632
+ export { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, mountChartSettings, normalizePreferences, validatePreferences };
633
+ export const iChart = { version: '2.0.7', createChart, inspectData, normalizeData, binData, applyTransforms, normalizeSpec, validateSpec, data, getCapabilities, getChartCapability, getPreferenceCapabilities, planChart, recommend, explainChart, contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, createPreferencesStore, defaultPreferences, normalizePreferences, mergePreferences, validatePreferences, applyPreferencesToSpec, mountChartSettings, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries, normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };
package/src/layout.mjs ADDED
@@ -0,0 +1,39 @@
1
+ /** Shared chart chrome layout for titles and font-aware spacing. */
2
+ export function fontSize(spec, role, fallback) {
3
+ const configured = Number(spec.theme?.typography?.[role]?.size);
4
+ if (Number.isFinite(configured) && configured > 0) return configured;
5
+ const raw = spec.theme?.typography?.[role]?.font || `${fallback}px`;
6
+ const match = String(raw).match(/(?:^|\s)(\d+(?:\.\d+)?)px(?:\s|$)/i);
7
+ return match ? Number(match[1]) : fallback;
8
+ }
9
+
10
+ export function titleLayout(spec) {
11
+ const hasTitle = Boolean(spec.title?.text), hasSubtitle = Boolean(spec.title?.subtitle);
12
+ if (!hasTitle && !hasSubtitle) return { titleY: null, subtitleY: null, bottom: 0 };
13
+ const titleSize = fontSize(spec, 'title', 16), subtitleSize = fontSize(spec, 'subtitle', 13);
14
+ const top = Math.max(12, Math.min(24, Number(spec.padding?.top || 0) / 2));
15
+ const titleY = hasTitle ? top + titleSize / 2 : null;
16
+ const titleBottom = hasTitle ? top + titleSize : top;
17
+ const subtitleY = hasSubtitle ? titleBottom + 6 + subtitleSize / 2 : null;
18
+ const bottom = hasSubtitle ? subtitleY + subtitleSize / 2 : titleBottom;
19
+ return { titleY, subtitleY, bottom };
20
+ }
21
+
22
+ export function estimateTextWidth(text, size = 12) {
23
+ return Math.max(size, String(text ?? '').length * size * 0.58);
24
+ }
25
+
26
+ export function axisLabelLayout(spec, labels = [], availableWidth = 0) {
27
+ const size = fontSize(spec, 'axis', 12), count = Math.max(1, labels.length);
28
+ const slot = Math.max(1, availableWidth / count), maxWidth = Math.max(size, ...labels.map(label => estimateTextWidth(label, size)));
29
+ const rotation = maxWidth > slot * 1.45 ? -45 : maxWidth > slot * 0.92 ? -30 : 0;
30
+ const radians = Math.abs(rotation) * Math.PI / 180;
31
+ const projectedWidth = rotation ? maxWidth * Math.cos(radians) + size * Math.sin(radians) : maxWidth;
32
+ const projectedHeight = rotation ? maxWidth * Math.sin(radians) + size * Math.cos(radians) : size;
33
+ const step = Math.max(1, Math.ceil(projectedWidth / Math.max(1, slot * 0.9)));
34
+ return { rotation, step, size, maxWidth, projectedHeight };
35
+ }
36
+
37
+ export function boxesOverlap(left, right, gap = 0) {
38
+ return left.x < right.x + right.width + gap && left.x + left.width + gap > right.x && left.y < right.y + right.height + gap && left.y + left.height + gap > right.y;
39
+ }
@@ -0,0 +1,135 @@
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 { getPreferenceCapabilities } from './capabilities.mjs';
6
+ import { contrastRatio } from './theme.mjs';
7
+
8
+ const styleId = 'ichart-preferences-ui-style';
9
+ const messages = {
10
+ 'zh-CN': { title: '图表快捷设置' },
11
+ en: { title: 'Chart quick settings' }
12
+ };
13
+ const placements = ['right', 'top', 'bottom'];
14
+ let panelSequence = 0;
15
+
16
+ function option(label, value) { return `<option value="${value}">${label}</option>`; }
17
+ function optionMarkup(field) { return field.options.map(item => option(item.label, item.value == null ? 'auto' : item.value)).join(''); }
18
+ function safe(value) { return value == null ? '' : String(value); }
19
+ function localeKey(locale) {
20
+ const requested = locale && locale !== 'auto' ? locale : document.documentElement.lang || navigator.language || 'en';
21
+ const normalized = String(requested).toLowerCase();
22
+ if (normalized === 'zh' || normalized.startsWith('zh-')) return 'zh-CN';
23
+ if (normalized === 'en' || normalized.startsWith('en-')) return 'en';
24
+ return 'en';
25
+ }
26
+
27
+ export function resolveChartSettingsPlacement(anchor, panel, viewport, preferredPlacements = placements, gap = 10, margin = 8) {
28
+ const boundary = { left: (viewport.left || 0) + margin, top: (viewport.top || 0) + margin, right: (viewport.left || 0) + viewport.width - margin, bottom: (viewport.top || 0) + viewport.height - margin };
29
+ const candidates = Object.fromEntries(placements.map(value => [value, chartSettingsPlacementCoordinates(anchor, panel, value, gap)]));
30
+ const order = [...new Set(preferredPlacements.filter(value => placements.includes(value)))];
31
+ if (!order.length) order.push(...placements);
32
+ const fits = candidate => candidate.left >= boundary.left && candidate.top >= boundary.top && candidate.left + panel.width <= boundary.right && candidate.top + panel.height <= boundary.bottom;
33
+ const selected = order.find(value => fits(candidates[value]));
34
+ if (selected) return { placement: selected, ...candidates[selected], maxHeight: Math.max(120, boundary.bottom - candidates[selected].top), constrained: false };
35
+ const placement = order.at(-1), candidate = candidates[placement], maximumLeft = Math.max(boundary.left, boundary.right - panel.width), maximumTop = Math.max(boundary.top, boundary.bottom - panel.height);
36
+ const left = Math.max(boundary.left, Math.min(maximumLeft, candidate.left)), top = Math.max(boundary.top, Math.min(maximumTop, candidate.top));
37
+ return { placement, left, top, maxHeight: Math.max(120, boundary.bottom - top), constrained: true };
38
+ }
39
+
40
+ export function chartSettingsPlacementCoordinates(anchor, panel, placement, gap = 10) {
41
+ if (placement === 'right') return { left: anchor.right + gap, top: anchor.top };
42
+ if (placement === 'top') return { left: anchor.right - panel.width, top: anchor.top - panel.height - gap };
43
+ return { left: anchor.right - panel.width, top: anchor.bottom + gap };
44
+ }
45
+
46
+ export function isChartSettingsAnchorVisible(anchor, host, viewport) {
47
+ const boundary = { left: viewport.left || 0, top: viewport.top || 0, right: (viewport.left || 0) + viewport.width, bottom: (viewport.top || 0) + viewport.height };
48
+ const intersects = rect => rect && rect.right > boundary.left && rect.left < boundary.right && rect.bottom > boundary.top && rect.top < boundary.bottom;
49
+ return intersects(host) && intersects(anchor);
50
+ }
51
+
52
+ function ensureStyle() {
53
+ if (typeof document === 'undefined' || document.getElementById(styleId)) return;
54
+ const style = document.createElement('style');
55
+ style.id = styleId;
56
+ 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:fixed;z-index:10000;width:min(320px,calc(100vw - 16px));padding:12px;border:0;border-radius:10px;background:#fff;color:#172033;pointer-events:auto;overflow-y:auto;box-shadow:0 12px 30px #1720332b;font:12px/1.4 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.ichart-preferences__panel[data-open="true"]{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,calc(100vw - 16px))}.ichart-preferences__grid,.ichart-preferences__toggles{grid-template-columns:1fr}.ichart-preferences__toggle{justify-content:flex-start}}`;
57
+ document.head.appendChild(style);
58
+ }
59
+
60
+ export function mountChartSettings(chart, { container = chart?.container, locale = 'auto', title, placement = 'auto', preferredPlacements = placements } = {}) {
61
+ if (typeof document === 'undefined' || !chart || !container) return { valid: false, code: 'PREFERENCES_UI_UNAVAILABLE', message: 'Chart settings require a browser container.' };
62
+ ensureStyle();
63
+ const resolvedLocale = localeKey(locale), text = messages[resolvedLocale], accessibleTitle = title || text.title, host = container;
64
+ const capabilities = getPreferenceCapabilities(chart.getSpec().type, { locale: resolvedLocale }), fields = new Map(capabilities.fields.map(field => [field.path, field]));
65
+ const modeField = fields.get('theme.mode'), paletteField = fields.get('theme.palette'), scaleField = fields.get('typography.scale');
66
+ const visibleComponents = ['legend', 'labels', 'grid'].filter(name => fields.get(`components.${name}`)?.menu.visible);
67
+ if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
68
+ const root = document.createElement('div');
69
+ root.className = 'ichart-preferences';
70
+ root.dataset.open = 'false';
71
+ const componentMarkup = visibleComponents.map(name => `<label class="ichart-preferences__toggle"><input type="checkbox" data-key="${name}"><span>${fields.get(`components.${name}`).label}</span></label>`).join('');
72
+ root.innerHTML = `<button class="ichart-preferences__button" type="button" aria-expanded="false"><span aria-hidden="true">≡</span></button>`;
73
+ const panel = document.createElement('div');
74
+ panel.className = 'ichart-preferences__panel'; panel.id = `ichart-preferences-panel-${++panelSequence}`; panel.dataset.open = 'false'; panel.setAttribute('role', 'dialog');
75
+ panel.innerHTML = `<div class="ichart-preferences__grid"><label class="ichart-preferences__field"><span>${modeField.label}</span><select data-key="mode">${optionMarkup(modeField)}</select></label><label class="ichart-preferences__field"><span>${paletteField.label}</span><select data-key="palette">${optionMarkup(paletteField)}</select></label><label class="ichart-preferences__field"><span>${scaleField.label}</span><select data-key="scale">${optionMarkup(scaleField)}</select></label></div>${componentMarkup ? `<div class="ichart-preferences__toggles">${componentMarkup}</div>` : ''}`;
76
+ const button = root.querySelector('.ichart-preferences__button');
77
+ button.setAttribute('aria-label', accessibleTitle); button.setAttribute('aria-controls', panel.id); button.title = accessibleTitle; panel.setAttribute('aria-label', accessibleTitle);
78
+ host.appendChild(root);
79
+ document.body.appendChild(panel);
80
+ const values = key => panel.querySelector(`[data-key="${key}"]`);
81
+ const readField = key => {
82
+ const control = values(key), value = control?.type === 'checkbox' ? control.checked : control?.value;
83
+ if (['mode', 'palette'].includes(key)) return { theme: { [key]: value === 'auto' ? null : value } };
84
+ if (key === 'scale') return { typography: { scale: Number(value) } };
85
+ if (visibleComponents.includes(key)) return { components: { [key]: Boolean(value) } };
86
+ return {};
87
+ };
88
+ const sync = () => {
89
+ const prefs = chart.getPreferences(), spec = chart.getSpec(), set = (key, value) => { if (values(key)) values(key).value = safe(value); };
90
+ set('mode', prefs.theme?.mode || 'auto'); set('palette', prefs.theme?.palette || 'auto');
91
+ const scaleControl = values('scale'), scale = Number(prefs.typography?.scale || 1), scaleValue = String(scale);
92
+ scaleControl.querySelector('option[data-current]')?.remove();
93
+ 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); }
94
+ scaleControl.value = scaleValue;
95
+ if (values('legend')) values('legend').checked = spec.legend?.visible !== false;
96
+ if (values('labels')) values('labels').checked = Boolean(spec.labels?.enabled);
97
+ if (values('grid')) values('grid').checked = spec.grid?.visible !== false;
98
+ };
99
+ const close = () => { root.dataset.open = 'false'; panel.dataset.open = 'false'; button.setAttribute('aria-expanded', 'false'); };
100
+ const placementOrder = placement === 'auto' ? preferredPlacements : [placement, ...preferredPlacements.filter(value => value !== placement)];
101
+ const positionPanel = ({ preservePlacement = false } = {}) => {
102
+ if (panel.dataset.open !== 'true') return;
103
+ const visual = globalThis.visualViewport, viewport = { left: visual?.offsetLeft || 0, top: visual?.offsetTop || 0, width: visual?.width || globalThis.innerWidth, height: visual?.height || globalThis.innerHeight };
104
+ const anchorRect = button.getBoundingClientRect();
105
+ if (!isChartSettingsAnchorVisible(anchorRect, host.getBoundingClientRect(), viewport)) { close(); return; }
106
+ const currentPlacement = panel.dataset.placement;
107
+ if (preservePlacement && placements.includes(currentPlacement)) {
108
+ const coordinates = chartSettingsPlacementCoordinates(anchorRect, panel.getBoundingClientRect(), currentPlacement);
109
+ panel.style.left = `${Math.round(coordinates.left)}px`; panel.style.top = `${Math.round(coordinates.top)}px`;
110
+ return;
111
+ }
112
+ panel.style.visibility = 'hidden'; panel.style.left = '0px'; panel.style.top = '0px'; panel.style.maxHeight = '';
113
+ const result = resolveChartSettingsPlacement(anchorRect, panel.getBoundingClientRect(), viewport, placementOrder);
114
+ panel.dataset.placement = result.placement; panel.dataset.constrained = String(result.constrained); panel.style.left = `${Math.round(result.left)}px`; panel.style.top = `${Math.round(result.top)}px`; panel.style.maxHeight = `${Math.floor(result.maxHeight)}px`; panel.style.visibility = 'visible';
115
+ };
116
+ 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); };
117
+ const onPreferencesChange = () => { sync(); updateIconColor(); positionPanel(); };
118
+ const applyField = key => { try { chart.setPreferences(readField(key), { source: 'ui' }); } catch {} };
119
+ const onToggle = () => { const open = root.dataset.open !== 'true'; root.dataset.open = String(open); panel.dataset.open = String(open); button.setAttribute('aria-expanded', String(open)); if (open) { sync(); positionPanel(); } };
120
+ const onDocumentPointer = event => { if (!root.contains(event.target) && !panel.contains(event.target)) close(); };
121
+ const onDocumentKey = event => { if (event.key === 'Escape') { close(); button.focus(); } };
122
+ const onViewportResize = () => positionPanel();
123
+ const onViewportScroll = () => positionPanel({ preservePlacement: true });
124
+ button.addEventListener('click', onToggle);
125
+ panel.querySelectorAll('select,input[data-key]').forEach(control => control.addEventListener('change', () => applyField(control.dataset.key)));
126
+ document.addEventListener('pointerdown', onDocumentPointer);
127
+ document.addEventListener('keydown', onDocumentKey);
128
+ globalThis.addEventListener?.('resize', onViewportResize); globalThis.addEventListener?.('scroll', onViewportScroll, true); globalThis.visualViewport?.addEventListener?.('resize', onViewportResize); globalThis.visualViewport?.addEventListener?.('scroll', onViewportScroll);
129
+ chart.on('preferenceschange', onPreferencesChange);
130
+ chart.on('themechange', updateIconColor);
131
+ const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onViewportResize); resizeObserver?.observe(host);
132
+ sync();
133
+ updateIconColor();
134
+ return { valid: true, element: root, panel, locale: resolvedLocale, capabilities, destroy() { chart.off('preferenceschange', onPreferencesChange); chart.off('themechange', updateIconColor); document.removeEventListener('pointerdown', onDocumentPointer); document.removeEventListener('keydown', onDocumentKey); globalThis.removeEventListener?.('resize', onViewportResize); globalThis.removeEventListener?.('scroll', onViewportScroll, true); globalThis.visualViewport?.removeEventListener?.('resize', onViewportResize); globalThis.visualViewport?.removeEventListener?.('scroll', onViewportScroll); resizeObserver?.disconnect(); panel.remove(); root.remove(); } };
135
+ }