@taylorwong/ichartjs 2.0.6 → 2.0.8

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 (53) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +2 -2
  3. package/agent-recipes/minimal-specs.json +25 -0
  4. package/docs/agent/README.md +3 -1
  5. package/docs/agent/charting-scenario.md +46 -1
  6. package/docs/agent/coding-agent-integration.md +1 -1
  7. package/docs/agent/development/iteration-12.md +101 -0
  8. package/docs/agent/development/roadmap.md +2 -1
  9. package/docs/agent/development-guide.md +1 -1
  10. package/docs/agent/diagram-scenario.md +39 -11
  11. package/docs/agent/editing-contract.md +5 -0
  12. package/docs/agent/frontend-integration.md +1 -1
  13. package/docs/agent/project-scenario.md +1 -0
  14. package/docs/agent/quickstart.md +30 -0
  15. package/docs/agent/runtime-contract.md +19 -2
  16. package/docs/agent/theme-guide.md +33 -1
  17. package/docs/agent/usage-scenarios.md +3 -1
  18. package/docs/agent/zh-CN/README.md +1 -1
  19. package/docs/agent/zh-CN/charting-scenario.md +31 -1
  20. package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
  21. package/docs/agent/zh-CN/diagram-scenario.md +32 -3
  22. package/docs/agent/zh-CN/editing-contract.md +2 -0
  23. package/docs/agent/zh-CN/frontend-integration.md +1 -1
  24. package/docs/agent/zh-CN/iteration-12.md +98 -0
  25. package/docs/agent/zh-CN/quickstart.md +30 -0
  26. package/docs/agent/zh-CN/runtime-contract.md +7 -1
  27. package/docs/agent/zh-CN/theme-guide.md +33 -1
  28. package/docs/agent/zh-CN/usage-scenarios.md +3 -1
  29. package/docs/manifests/capabilities.json +42 -3
  30. package/docs/manifests/commands.json +10 -9
  31. package/docs/manifests/schemas.json +5 -2
  32. package/examples/agent-workflow.mjs +2 -1
  33. package/package.json +1 -1
  34. package/skills/ichartjs/SKILL.md +19 -9
  35. package/skills/ichartjs/references/agent-contract.md +5 -2
  36. package/skills/ichartjs/references/chart-selection.md +2 -0
  37. package/src/capabilities.mjs +83 -13
  38. package/src/charts.mjs +202 -58
  39. package/src/command.mjs +4 -3
  40. package/src/diagram-interaction.mjs +55 -11
  41. package/src/diagram.mjs +149 -36
  42. package/src/edit-controller.mjs +6 -3
  43. package/src/edit.mjs +17 -7
  44. package/src/index.mjs +44 -17
  45. package/src/layout.mjs +39 -0
  46. package/src/preferences-ui.mjs +73 -24
  47. package/src/project.mjs +64 -24
  48. package/src/renderer.mjs +2 -2
  49. package/src/scene.mjs +32 -1
  50. package/src/schema.mjs +7 -2
  51. package/src/spec.mjs +88 -6
  52. package/src/theme.mjs +1 -1
  53. package/types/index.d.ts +28 -7
package/src/index.mjs CHANGED
@@ -16,11 +16,11 @@ 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';
23
- import { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, normalizePreferences } from './preferences.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
24
  import { mountChartSettings } from './preferences-ui.mjs';
25
25
 
26
26
  import { isDiagram, paintSelection, diagramPointer, diagramKeyboard } from './diagram-interaction.mjs';
@@ -101,10 +101,13 @@ function sceneToSvgString(scene, spec = {}) {
101
101
  }
102
102
  attrs.push(`d="${_svgEscape(d, 'attr')}"`);
103
103
  } else if (node.type === 'path') {
104
- 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' : ''}`;
105
107
  attrs.push(`d="${_svgEscape(d, 'attr')}"`);
106
108
  } else if (node.type === 'text') {
107
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})"`);
108
111
  const anchor = s.textAnchor || (s.textAlign === 'end' ? 'end' : s.textAlign === 'center' ? 'middle' : s.textAlign === 'right' ? 'end' : s.textAlign === 'left' ? 'start' : 'start');
109
112
  if (anchor) attrs.push(`text-anchor="${anchor}"`);
110
113
  const baseline = s.textBaseline || s.baseline || 'alphabetic';
@@ -195,7 +198,7 @@ export class Chart {
195
198
  delete result.spec.preferencesStore;
196
199
  delete result.spec.chartId;
197
200
  this._themeInput = input.theme ?? 'auto';
198
- 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 };
199
202
  this.spec = result.spec;
200
203
  this._preferenceBase = Object.fromEntries(['legend', 'labels', 'grid', 'branding', 'padding'].map(key => [key, clone(this.spec[key])]));
201
204
  this._resolveStyle();
@@ -232,6 +235,7 @@ export class Chart {
232
235
  if (!this._styleOverrides.background) this.spec.background = this.spec.theme.background;
233
236
  if (!this._styleOverrides.padding) this.spec.padding = clone(this.spec.theme.layout.padding);
234
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]); });
235
239
  }
236
240
  _observeColorScheme() {
237
241
  if (typeof matchMedia !== 'function') return;
@@ -265,9 +269,13 @@ export class Chart {
265
269
  element.setAttribute('role', 'group');
266
270
  element.setAttribute('aria-label', `Group ${dataRef.groupId}${dataRef.collapsed ? ', collapsed' : ', expanded'}`);
267
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}`);
268
275
  } else if (dataRef.edgeId || dataRef.from && dataRef.to) {
269
276
  element.setAttribute('role', 'img');
270
277
  element.setAttribute('aria-label', `Edge from ${dataRef.from} to ${dataRef.to}`);
278
+ element.setAttribute('aria-selected', String(this.getSelectedEdgeIds().includes(dataRef.edgeId)));
271
279
  if (dataRef.from) element.setAttribute('aria-describedby', `node-${dataRef.from}`);
272
280
  } else if (dataRef.nodeId && !dataRef.portId) {
273
281
  element.setAttribute('role', 'button');
@@ -288,7 +296,7 @@ export class Chart {
288
296
  const ids = [];
289
297
  this.model.scene.walk(node => { if (node.interactive && node.dataRef) ids.push(node.id); });
290
298
  if (!ids.length) return;
291
- 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) {
292
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 };
293
301
  const result = this.moveSelectedBy(delta, { confirmed: true, source: 'keyboard' });
294
302
  if (result.valid) event.preventDefault();
@@ -318,16 +326,16 @@ export class Chart {
318
326
  const touchEnd = () => { this._touchStart = null; this._pinchStart = null; };
319
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)); };
320
328
  const leave = event => { this._hideTooltip(); if (!this._dragStart && !this._nodeDrag) end(event); };
321
- 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 }; }
322
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); }
323
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) }; }
324
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); }
325
- _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`; }
326
334
  _hideTooltip() { if (this._tooltip) this._tooltip.style.left = '-10000px'; }
327
335
  on(type, listener) { if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type).add(listener); return this; }
328
336
  off(type, listener) { this.listeners.get(type)?.delete(listener); return this; }
329
337
  emit(type, event) { this.listeners.get(type)?.forEach(listener => listener(event)); }
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(); }
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(); }
331
339
  setData(data) { this._editor.invalidate(); this.spec = normalizeSpec({ ...this.spec, theme: this._themeInput, data: { values: data } }); this._resolveStyle(); return this.render(); }
332
340
  setTheme(theme = 'auto') { this._themeInput = theme; this._resolveStyle(); this.render(); this.emit('themechange', { chart: this, theme: this.getTheme(), source: 'user' }); return this; }
333
341
  getTheme() { return clone(this.spec.theme); }
@@ -336,13 +344,21 @@ export class Chart {
336
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; }
337
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; }
338
346
  getSpec() { return JSON.parse(JSON.stringify(this.spec)); }
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) }; }
347
+ _getDiagnostics() { return [...(this._specDiagnostics?.warnings || []), ...(this.model.data?.warnings || []), ...(this.spec.theme?.warnings || [])]; }
348
+ _getHealth(warnings) {
349
+ const isEmpty = this.model.data.rows.length === 0 && !['flow', 'swimlane', 'architecture', 'mindmap'].includes(this.spec.type) || warnings.some(item => item.code === 'ZERO_TOTAL');
350
+ const marks = [];
351
+ this.model.scene?.walk?.(node => { if (node.id !== 'root' && !['text', 'line'].includes(node.type)) marks.push(node); });
352
+ const suppressedLabels = warnings.reduce((sum, item) => sum + Number(item.count || 0), 0);
353
+ return { version: '1.0', status: isEmpty ? 'empty' : warnings.length ? 'degraded' : 'ready', renderable: !isEmpty, issues: [...new Set(warnings.map(item => item.code))], metrics: { warnings: warnings.length, suppressedLabels, clampedValues: warnings.filter(item => item.code === 'VALUE_CLAMPED').length, renderedMarks: marks.length } };
354
+ }
355
+ getState() { const brandingSignature = discoverCapabilities().branding.signature, warnings = this._getDiagnostics(); 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 }), axes: clone(this.model.state?.axes || null), health: this._getHealth(warnings), preferences: this.getPreferences(), branding: { enabled: Boolean(this.spec.branding?.enabled), signature: brandingSignature, text: this.spec.branding?.enabled === true ? brandingSignature : null }, warnings: clone(warnings), assumptions: clone(this.model.data?.assumptions || []), normalizations: clone(this._specDiagnostics?.normalizations || []), collapsedGroups: this.getCollapsedGroupIds(), clipboard: { nodes: this._clipboard?.nodes?.length || 0, edges: this._clipboard?.edges?.length || 0 }, projectAnalytics: clone(this.model.state?.projectAnalytics || null), linked: clone(this.model.state?.linked || null) }; }
340
356
  getProjectAnalytics() { return clone(this.model.state?.projectAnalytics || null); }
341
357
  getLinkedState() { return clone(this.model.state?.linked || null); }
342
358
  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(); }
343
359
  setLinkedSelection(selection = []) { this.spec.project = { ...(this.spec.project || {}), linked: { ...(this.spec.project?.linked || {}), selection: normalizeLinkedSelection(selection) } }; this.emit('linkedstatechange', { chart: this, linked: this.spec.project.linked }); return this.render(); }
344
360
  describe() { return { type: this.spec.type, renderer: this.renderer.constructor.name, dimensions: [this.spec.encoding.x?.field || this.spec.encoding.category?.field], measures: (Array.isArray(this.spec.encoding.y) ? this.spec.encoding.y : [this.spec.encoding.y || this.spec.encoding.value]).filter(Boolean).map(encoding => encoding.field), dataCount: this.model.data.rows.length, theme: this.spec.theme?.name || 'custom', interactions: Object.keys(this.spec.interaction || {}).filter(key => this.spec.interaction[key]) }; }
345
- explain() { return explainChart(this.spec, this.model); }
361
+ explain() { const explanation = explainChart(this.spec, this.model), state = this.getState(); return { ...explanation, warnings: state.warnings, health: state.health }; }
346
362
  getAccessibleDescription() { const description = this.spec.accessibility?.description || this.spec.title?.text || `${this.spec.type} chart`; return `${description}; ${this.model.data.rows.length} data items.`; }
347
363
  inspectDataSchema() { return inspectDataSchema(this.spec.data.schema || this.spec.schema); }
348
364
  validateData() { return validateData(this.toDataTable(), this.spec.data.schema || this.spec.schema, this.spec.validationOptions); }
@@ -356,8 +372,10 @@ export class Chart {
356
372
  highlight(target) { if (target?.id) target.highlighted = true; this.render(); return this; }
357
373
  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; }
358
374
  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; }
375
+ 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; }
359
376
  selectGroup(groupId, options = {}) { return this.selectNodes(this.model.data.rows.filter(row => row.groupId === groupId).map(row => row.id), options); }
360
377
  getSelectedNodeIds() { return [...new Set([...this._selected.values()].map(ref => ref?.nodeId).filter(Boolean))]; }
378
+ getSelectedEdgeIds() { return [...new Set([...this._selected.values()].map(ref => ref?.edgeId).filter(Boolean))]; }
361
379
  getDiagramNodes() { return (this.spec.nodes || this.spec.data?.nodes || []).map(node => ({ ...node })); }
362
380
  getDiagramEdges() { return (this.spec.edges || this.spec.data?.edges || []).map(edge => ({ ...edge })); }
363
381
  getDiagramGroups() { return (this.spec.groups || this.spec.data?.groups || []).map(group => ({ ...group })); }
@@ -400,6 +418,15 @@ export class Chart {
400
418
  const preview = this.previewEdit({ type: 'layout-edit', reason: 'Connect diagram nodes', operations: [operation] });
401
419
  return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed });
402
420
  }
421
+ deleteSelectedEdges(options = {}) {
422
+ const edgeIds = this.getSelectedEdgeIds();
423
+ if (!edgeIds.length) return { valid: false, errors: [{ code: 'NO_SELECTION', message: 'Select one or more diagram edges first.' }] };
424
+ if (this.spec.editing?.allowDelete !== true) return { valid: false, errors: [{ code: 'DELETE_DISABLED', message: 'Edge deletion requires editing.allowDelete.' }] };
425
+ const preview = this.previewEdit({ type: 'layout-edit', reason: 'Delete selected diagram edges', operations: edgeIds.map(edgeId => ({ op: 'removeEdge', edgeId })) });
426
+ const result = this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed });
427
+ if (result.valid) this.clearSelection();
428
+ return result;
429
+ }
403
430
  toggleGroupCollapse(groupId, options = {}) {
404
431
  const preview = this.previewEdit({ type: 'layout-edit', reason: 'Toggle group collapse', operations: [{ op: 'toggleGroupCollapse', groupId, ...(options.collapsed !== undefined ? { collapsed: options.collapsed } : {}) }] });
405
432
  const result = this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed });
@@ -414,7 +441,7 @@ export class Chart {
414
441
  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 }); }
415
442
  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 }); }
416
443
  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 }); }
417
- 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); }
444
+ 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); }
418
445
  clearSelection() { this._selected.clear(); this.render(); this.emit('selectionchange', { chart: this, target: null, selectedData: [] }); return this; }
419
446
  getElementAt(x, y) { return this.model.scene.hit(x, y); }
420
447
  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; }
@@ -602,13 +629,13 @@ export class Chart {
602
629
  }
603
630
 
604
631
  export function createChart(spec) { return new Chart(spec); }
605
- 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' } }; }
632
+ 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' } }; }
606
633
  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 }; }
607
- export { normalizeSpec, validateSpec, normalizeData, inspectData, binData, applyTransforms, data, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, getChartCapability, planChart, explainChart };
608
- export { diagramLayoutModes, edgeRoutingModes, normalizeDiagramSpec, validateDiagram, layoutDiagram, routeEdge };
634
+ export { normalizeSpec, validateSpec, normalizeData, inspectData, binData, applyTransforms, data, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, getChartCapability, getPreferenceCapabilities, planChart, explainChart };
635
+ export { diagramLayoutModes, diagramModes, edgeRoutingModes, normalizeDiagramSpec, validateDiagram, layoutDiagram, routeEdge };
609
636
  export { normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries };
610
637
  export { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };
611
638
 
612
639
  export { contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin };
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 };
640
+ export { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, mountChartSettings, normalizePreferences, validatePreferences };
641
+ export const iChart = { version: '2.0.8', 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
+ }
@@ -2,20 +2,19 @@
2
2
  * Optional, framework-neutral quick settings popover for browser hosts.
3
3
  * It stays outside the SVG/Canvas surface and is never part of exports.
4
4
  */
5
- import { getChartCapability } from './capabilities.mjs';
6
- import { contrastRatio, themeModes, themePalettes } from './theme.mjs';
5
+ import { getPreferenceCapabilities } from './capabilities.mjs';
6
+ import { contrastRatio } from './theme.mjs';
7
7
 
8
8
  const styleId = 'ichart-preferences-ui-style';
9
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
- }
10
+ 'zh-CN': { title: '图表快捷设置' },
11
+ en: { title: 'Chart quick settings' }
16
12
  };
13
+ const placements = ['right', 'top', 'bottom'];
14
+ let panelSequence = 0;
17
15
 
18
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(''); }
19
18
  function safe(value) { return value == null ? '' : String(value); }
20
19
  function localeKey(locale) {
21
20
  const requested = locale && locale !== 'auto' ? locale : document.documentElement.lang || navigator.language || 'en';
@@ -25,30 +24,60 @@ function localeKey(locale) {
25
24
  return 'en';
26
25
  }
27
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
+
28
52
  function ensureStyle() {
29
53
  if (typeof document === 'undefined' || document.getElementById(styleId)) return;
30
54
  const style = document.createElement('style');
31
55
  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}}`;
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}}`;
33
57
  document.head.appendChild(style);
34
58
  }
35
59
 
36
- export function mountChartSettings(chart, { container = chart?.container, locale = 'auto', title } = {}) {
60
+ export function mountChartSettings(chart, { container = chart?.container, locale = 'auto', title, placement = 'auto', preferredPlacements = placements } = {}) {
37
61
  if (typeof document === 'undefined' || !chart || !container) return { valid: false, code: 'PREFERENCES_UI_UNAVAILABLE', message: 'Chart settings require a browser container.' };
38
62
  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);
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);
42
67
  if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
43
68
  const root = document.createElement('div');
44
69
  root.className = 'ichart-preferences';
45
70
  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);
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);
50
78
  host.appendChild(root);
51
- const values = key => root.querySelector(`[data-key="${key}"]`);
79
+ document.body.appendChild(panel);
80
+ const values = key => panel.querySelector(`[data-key="${key}"]`);
52
81
  const readField = key => {
53
82
  const control = values(key), value = control?.type === 'checkbox' ? control.checked : control?.value;
54
83
  if (['mode', 'palette'].includes(key)) return { theme: { [key]: value === 'auto' ? null : value } };
@@ -67,20 +96,40 @@ export function mountChartSettings(chart, { container = chart?.container, locale
67
96
  if (values('labels')) values('labels').checked = Boolean(spec.labels?.enabled);
68
97
  if (values('grid')) values('grid').checked = spec.grid?.visible !== false;
69
98
  };
70
- const close = () => { root.dataset.open = 'false'; button.setAttribute('aria-expanded', 'false'); };
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
+ };
71
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); };
72
- const onPreferencesChange = () => { sync(); updateIconColor(); };
117
+ const onPreferencesChange = () => { sync(); updateIconColor(); positionPanel(); };
73
118
  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(); };
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(); };
76
121
  const onDocumentKey = event => { if (event.key === 'Escape') { close(); button.focus(); } };
122
+ const onViewportResize = () => positionPanel();
123
+ const onViewportScroll = () => positionPanel({ preservePlacement: true });
77
124
  button.addEventListener('click', onToggle);
78
- root.querySelectorAll('select,input[data-key]').forEach(control => control.addEventListener('change', () => applyField(control.dataset.key)));
125
+ panel.querySelectorAll('select,input[data-key]').forEach(control => control.addEventListener('change', () => applyField(control.dataset.key)));
79
126
  document.addEventListener('pointerdown', onDocumentPointer);
80
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);
81
129
  chart.on('preferenceschange', onPreferencesChange);
82
130
  chart.on('themechange', updateIconColor);
131
+ const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onViewportResize); resizeObserver?.observe(host);
83
132
  sync();
84
133
  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(); } };
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(); } };
86
135
  }