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