@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.
- package/CHANGELOG.md +13 -0
- package/README.md +2 -2
- package/agent-recipes/minimal-specs.json +25 -0
- package/docs/agent/README.md +3 -1
- package/docs/agent/charting-scenario.md +46 -1
- package/docs/agent/coding-agent-integration.md +1 -1
- package/docs/agent/development/iteration-12.md +101 -0
- package/docs/agent/development/roadmap.md +2 -1
- package/docs/agent/development-guide.md +1 -1
- package/docs/agent/diagram-scenario.md +39 -11
- package/docs/agent/editing-contract.md +5 -0
- package/docs/agent/frontend-integration.md +1 -1
- package/docs/agent/project-scenario.md +1 -0
- package/docs/agent/quickstart.md +30 -0
- package/docs/agent/runtime-contract.md +19 -2
- package/docs/agent/theme-guide.md +33 -1
- package/docs/agent/usage-scenarios.md +3 -1
- package/docs/agent/zh-CN/README.md +1 -1
- package/docs/agent/zh-CN/charting-scenario.md +31 -1
- package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
- package/docs/agent/zh-CN/diagram-scenario.md +32 -3
- package/docs/agent/zh-CN/editing-contract.md +2 -0
- package/docs/agent/zh-CN/frontend-integration.md +1 -1
- package/docs/agent/zh-CN/iteration-12.md +98 -0
- package/docs/agent/zh-CN/quickstart.md +30 -0
- package/docs/agent/zh-CN/runtime-contract.md +7 -1
- package/docs/agent/zh-CN/theme-guide.md +33 -1
- package/docs/agent/zh-CN/usage-scenarios.md +3 -1
- package/docs/manifests/capabilities.json +42 -3
- package/docs/manifests/commands.json +10 -9
- package/docs/manifests/schemas.json +5 -2
- package/examples/agent-workflow.mjs +2 -1
- package/package.json +1 -1
- package/skills/ichartjs/SKILL.md +19 -9
- package/skills/ichartjs/references/agent-contract.md +5 -2
- package/skills/ichartjs/references/chart-selection.md +2 -0
- package/src/capabilities.mjs +83 -13
- package/src/charts.mjs +202 -58
- package/src/command.mjs +4 -3
- package/src/diagram-interaction.mjs +55 -11
- package/src/diagram.mjs +149 -36
- package/src/edit-controller.mjs +6 -3
- package/src/edit.mjs +17 -7
- package/src/index.mjs +44 -17
- package/src/layout.mjs +39 -0
- package/src/preferences-ui.mjs +73 -24
- package/src/project.mjs +64 -24
- package/src/renderer.mjs +2 -2
- package/src/scene.mjs +32 -1
- package/src/schema.mjs +7 -2
- package/src/spec.mjs +88 -6
- package/src/theme.mjs +1 -1
- 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 =
|
|
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 (
|
|
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'
|
|
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
|
-
|
|
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() {
|
|
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 =
|
|
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.
|
|
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
|
+
}
|
package/src/preferences-ui.mjs
CHANGED
|
@@ -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 {
|
|
6
|
-
import { contrastRatio
|
|
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
|
-
|
|
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:
|
|
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
|
|
40
|
-
const
|
|
41
|
-
const
|
|
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>${
|
|
47
|
-
root.innerHTML = `<button class="ichart-preferences__button" type="button" aria-expanded="false"><span aria-hidden="true">≡</span></button
|
|
48
|
-
const
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
}
|