@xh/hoist 74.0.0-SNAPSHOT.1749232318711 → 74.0.0-SNAPSHOT.1749597895949
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 +6 -0
- package/build/types/cmp/chart/ChartModel.d.ts +8 -3
- package/build/types/cmp/chart/Types.d.ts +20 -0
- package/build/types/cmp/chart/impl/ChartContextMenuItems.d.ts +4 -0
- package/build/types/core/types/Interfaces.d.ts +32 -9
- package/build/types/desktop/cmp/contextmenu/ContextMenu.d.ts +2 -7
- package/build/types/desktop/cmp/dash/canvas/impl/utils.d.ts +2 -1
- package/build/types/desktop/cmp/panel/Panel.d.ts +1 -2
- package/build/types/desktop/hooks/UseContextMenu.d.ts +1 -1
- package/cmp/chart/Chart.ts +14 -56
- package/cmp/chart/ChartModel.ts +44 -12
- package/cmp/chart/Types.ts +33 -0
- package/cmp/chart/impl/ChartContextMenuItems.ts +129 -0
- package/cmp/chart/impl/zoomout.ts +2 -1
- package/core/types/Interfaces.ts +43 -10
- package/desktop/cmp/button/AppMenuButton.ts +3 -7
- package/desktop/cmp/contextmenu/ContextMenu.ts +10 -19
- package/desktop/cmp/dash/canvas/DashCanvas.ts +2 -1
- package/desktop/cmp/dash/canvas/impl/DashCanvasContextMenu.ts +4 -4
- package/desktop/cmp/dash/canvas/impl/utils.ts +2 -1
- package/desktop/cmp/dash/container/DashContainerModel.ts +2 -1
- package/desktop/cmp/dash/container/impl/DashContainerContextMenu.ts +5 -5
- package/desktop/cmp/panel/Panel.ts +2 -2
- package/desktop/hooks/UseContextMenu.ts +5 -4
- package/mobile/cmp/menu/impl/Menu.ts +3 -7
- package/package.json +1 -1
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,12 +10,18 @@
|
|
|
10
10
|
* Added `ViewManagerModel.preserveUnsavedChanges` flag to opt-out of that behaviour.
|
|
11
11
|
* Added `PersistOptions.settleTime` to configure time to wait for state to settle before persisting.
|
|
12
12
|
* Support for gridcolumn level `onCellClicked` events.
|
|
13
|
+
* General improvements to `MenuItem` api
|
|
14
|
+
* New `MenuContext` object now sent as 2nd arg to `actionFn` and `prepareFn`.
|
|
15
|
+
* Chart context menu is now fully customizable.
|
|
16
|
+
⚠️ NOTE: if a chart's context menu is turned off with `showContextMenu: false`,
|
|
17
|
+
update to `contextMenu: false`.
|
|
13
18
|
|
|
14
19
|
### 🐞 Bug Fixes
|
|
15
20
|
* Improved `ViewManagerModel.settleTime` by delegating to individual `PersistenceProviders`.
|
|
16
21
|
* Fixed bug where grid column state could become unintentionally dirty when columns were hidden.
|
|
17
22
|
* Improved `WebsocketService` heartbeat detection to auto-reconnect when the socket reports as open
|
|
18
23
|
and heartbeats can be sent, but no heartbeat acknowledgements are being received from the server.
|
|
24
|
+
* Restored zoom out with mouse right-to-left drag on Charts.
|
|
19
25
|
|
|
20
26
|
## v73.0.1 - 2025-05-19
|
|
21
27
|
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import type { ChartContextMenuSpec, ChartMenuToken } from '@xh/hoist/cmp/chart/Types';
|
|
1
2
|
import { HoistModel, PlainObject, Some } from '@xh/hoist/core';
|
|
2
3
|
interface ChartConfig {
|
|
3
4
|
/** The initial highchartsConfig for this chart. */
|
|
4
5
|
highchartsConfig: PlainObject;
|
|
5
6
|
/** The initial data series to be displayed. */
|
|
6
7
|
series?: Some<any>;
|
|
7
|
-
/**
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* True (default) to show default ContextMenu. Supported on desktop only.
|
|
10
|
+
*/
|
|
11
|
+
contextMenu?: ChartContextMenuSpec;
|
|
9
12
|
/** @internal */
|
|
10
13
|
xhImpl?: boolean;
|
|
11
14
|
}
|
|
@@ -15,7 +18,8 @@ interface ChartConfig {
|
|
|
15
18
|
export declare class ChartModel extends HoistModel {
|
|
16
19
|
highchartsConfig: PlainObject;
|
|
17
20
|
series: any[];
|
|
18
|
-
|
|
21
|
+
contextMenu: ChartContextMenuSpec;
|
|
22
|
+
static defaultContextMenu: ChartMenuToken[];
|
|
19
23
|
/**
|
|
20
24
|
* The HighCharts instance currently being displayed. This may be used for reading
|
|
21
25
|
* information about the chart, but any mutations to the chart should
|
|
@@ -44,5 +48,6 @@ export declare class ChartModel extends HoistModel {
|
|
|
44
48
|
setSeries(series: any | any[]): void;
|
|
45
49
|
/** Remove all series from this chart. */
|
|
46
50
|
clear(): void;
|
|
51
|
+
private parseContextMenu;
|
|
47
52
|
}
|
|
48
53
|
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ChartModel } from '@xh/hoist/cmp/chart/ChartModel';
|
|
2
|
+
import type { ContextMenuSpec, MenuContext, MenuToken } from '@xh/hoist/core';
|
|
3
|
+
/**
|
|
4
|
+
* Highcharts supported tokens {@link https://api.highcharts.com/highcharts/exporting.buttons.contextButton.menuItems}
|
|
5
|
+
* plus Hoist's `copyToClipboard`.
|
|
6
|
+
*/
|
|
7
|
+
export type ChartMenuToken = 'viewFullscreen' | 'printChart' | 'downloadJPEG' | 'downloadPNG' | 'downloadSVG' | 'downloadCSV' | 'downloadXLS' | 'downloadPDF' | 'copyToClipboard' | MenuToken;
|
|
8
|
+
export interface ChartMenuContext extends MenuContext {
|
|
9
|
+
chartModel: ChartModel;
|
|
10
|
+
/**
|
|
11
|
+
* Single point is the active series point the mouse is closest to
|
|
12
|
+
*/
|
|
13
|
+
point: any;
|
|
14
|
+
/**
|
|
15
|
+
* Points array is the list of points hovered over in each series. When
|
|
16
|
+
* there are multiple series and tooltip.shared = true, points.length less than 1.
|
|
17
|
+
*/
|
|
18
|
+
points: any[];
|
|
19
|
+
}
|
|
20
|
+
export type ChartContextMenuSpec = ContextMenuSpec<ChartMenuToken, ChartMenuContext>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ChartMenuContext, ChartMenuToken } from '@xh/hoist/cmp/chart/Types';
|
|
2
|
+
import { type MenuItem, type MenuItemLike } from '@xh/hoist/core';
|
|
3
|
+
/** @internal */
|
|
4
|
+
export declare function getContextMenuItems(items: MenuItemLike<ChartMenuToken>[], context: ChartMenuContext): (MenuItem<ChartMenuToken, ChartMenuContext> | '-')[];
|
|
@@ -211,12 +211,35 @@ export interface TrackOptions {
|
|
|
211
211
|
/** Optional flag to omit sending message. */
|
|
212
212
|
omit?: Thunkable<boolean>;
|
|
213
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* The base `MenuToken` type. '-' is interpreted as the standard textless divider.
|
|
216
|
+
* Components will likely extend this type to support other strings like 'copyToClipboard',
|
|
217
|
+
* 'print', etc. which the component then converts into a {@link MenuItem}.
|
|
218
|
+
*/
|
|
219
|
+
export type MenuToken = '-';
|
|
220
|
+
/**
|
|
221
|
+
* `MenuContext` is the set of contextual arguments passed to a {@link MenuItem}'s
|
|
222
|
+
* `actionFn` and `prepareFn`. `contextMenuEvent` is the right click event that opened the
|
|
223
|
+
* context menu. It is optional because the `contextMenu` component can also be used on
|
|
224
|
+
* popover buttons, where there is no `contextMenuEvent`.
|
|
225
|
+
*
|
|
226
|
+
* Components offering a built-in {@link contextMenu} can extend `MenuContext` to add values
|
|
227
|
+
* relevant to the component. See for example {@link ChartMenuContext}.
|
|
228
|
+
*/
|
|
229
|
+
export interface MenuContext {
|
|
230
|
+
contextMenuEvent?: MouseEvent | PointerEvent;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* A context menu is specified as an array of items, a function to generate one from a click, or
|
|
234
|
+
* a full element representing a contextMenu Component.
|
|
235
|
+
*/
|
|
236
|
+
export type ContextMenuSpec<T = MenuToken, C = MenuContext> = MenuItemLike<T, C>[] | ((e: MouseEvent | PointerEvent, context: C) => MenuItemLike<T, C>[]) | boolean;
|
|
214
237
|
/**
|
|
215
238
|
* Basic interface for a MenuItem to appear in a menu.
|
|
216
239
|
*
|
|
217
240
|
* MenuItems can be displayed within a context menu, or shown when clicking on a button.
|
|
218
241
|
*/
|
|
219
|
-
export interface MenuItem {
|
|
242
|
+
export interface MenuItem<T = MenuToken, C = MenuContext> {
|
|
220
243
|
/** Label to be displayed. */
|
|
221
244
|
text: ReactNode;
|
|
222
245
|
/** Icon to be displayed. */
|
|
@@ -226,11 +249,11 @@ export interface MenuItem {
|
|
|
226
249
|
/** Css class name to be added when rendering the menu item. */
|
|
227
250
|
className?: string;
|
|
228
251
|
/** Executed when the user clicks the menu item. */
|
|
229
|
-
actionFn?: (e: MouseEvent | PointerEvent) => void;
|
|
252
|
+
actionFn?: (e: MouseEvent | PointerEvent, context?: C) => void;
|
|
230
253
|
/** Executed before the item is shown. Use to adjust properties dynamically. */
|
|
231
|
-
prepareFn?: (me: MenuItem) => void;
|
|
254
|
+
prepareFn?: (me: MenuItem<T, C>, context?: C) => void;
|
|
232
255
|
/** Child menu items. */
|
|
233
|
-
items?: MenuItemLike[];
|
|
256
|
+
items?: MenuItemLike<T, C>[];
|
|
234
257
|
/** True to disable this item. */
|
|
235
258
|
disabled?: boolean;
|
|
236
259
|
/** True to hide this item. May be set dynamically via prepareFn. */
|
|
@@ -240,12 +263,12 @@ export interface MenuItem {
|
|
|
240
263
|
}
|
|
241
264
|
/**
|
|
242
265
|
* An item that can exist in a Menu.
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
* be de-duped if appearing at the beginning, or end, or adjacent to another divider at render time.
|
|
266
|
+
* Components may accept token strings, in addition, '-' will be interpreted as the standard
|
|
267
|
+
* textless divider that will also be de-duped if appearing at the beginning, or end, or adjacent
|
|
268
|
+
* to another divider at render time. Also allows for a ReactNode for flexible display.
|
|
247
269
|
*/
|
|
248
|
-
export type MenuItemLike = MenuItem |
|
|
270
|
+
export type MenuItemLike<T = MenuToken, C = MenuContext> = MenuItem<T, C> | T | ReactElement;
|
|
271
|
+
export declare function isMenuItem<T, C>(item: MenuItemLike<T, C>): item is MenuItem<T, C>;
|
|
249
272
|
/**
|
|
250
273
|
* An option to be passed to Select controls
|
|
251
274
|
*/
|
|
@@ -1,13 +1,8 @@
|
|
|
1
|
-
import { HoistProps, MenuItemLike } from '@xh/hoist/core';
|
|
1
|
+
import { HoistProps, MenuContext, MenuItemLike } from '@xh/hoist/core';
|
|
2
2
|
import '@xh/hoist/desktop/register';
|
|
3
|
-
import { ReactElement } from 'react';
|
|
4
|
-
/**
|
|
5
|
-
* A context menu is specified as an array of items, a function to generate one from a click, or
|
|
6
|
-
* a full element representing a contextMenu Component.
|
|
7
|
-
*/
|
|
8
|
-
export type ContextMenuSpec = MenuItemLike[] | ((e: MouseEvent) => MenuItemLike[]) | ReactElement;
|
|
9
3
|
export interface ContextMenuProps extends HoistProps {
|
|
10
4
|
menuItems: MenuItemLike[];
|
|
5
|
+
context?: MenuContext;
|
|
11
6
|
}
|
|
12
7
|
/**
|
|
13
8
|
* Component for a right-click context menu. Not typically used directly by applications - use
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type MenuItemLike } from '@xh/hoist/core';
|
|
1
2
|
/**
|
|
2
3
|
* Used to create view menu items (for adding or replacing views)
|
|
3
4
|
* @internal
|
|
@@ -7,4 +8,4 @@ export declare function createViewMenuItems({ dashCanvasModel, position, viewId,
|
|
|
7
8
|
position?: any;
|
|
8
9
|
viewId?: any;
|
|
9
10
|
replaceExisting?: boolean;
|
|
10
|
-
}):
|
|
11
|
+
}): MenuItemLike[];
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { BoxProps, HoistProps, Some, TaskObserver } from '@xh/hoist/core';
|
|
1
|
+
import { BoxProps, HoistProps, Some, TaskObserver, type ContextMenuSpec } from '@xh/hoist/core';
|
|
2
2
|
import '@xh/hoist/desktop/register';
|
|
3
3
|
import { HotkeyConfig } from '@xh/hoist/kit/blueprint';
|
|
4
4
|
import { ReactElement, ReactNode } from 'react';
|
|
5
|
-
import { ContextMenuSpec } from '../contextmenu/ContextMenu';
|
|
6
5
|
import './Panel.scss';
|
|
7
6
|
import { PanelModel } from './PanelModel';
|
|
8
7
|
export interface PanelProps extends HoistProps<PanelModel>, Omit<BoxProps, 'title'> {
|
package/cmp/chart/Chart.ts
CHANGED
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
XH
|
|
20
20
|
} from '@xh/hoist/core';
|
|
21
21
|
import {useContextMenu} from '@xh/hoist/dynamics/desktop';
|
|
22
|
-
import {Icon} from '@xh/hoist/icon';
|
|
23
22
|
import {Highcharts} from '@xh/hoist/kit/highcharts';
|
|
24
23
|
import {runInAction} from '@xh/hoist/mobx';
|
|
25
24
|
import {logError, mergeDeep} from '@xh/hoist/utils/js';
|
|
@@ -95,7 +94,8 @@ export const [Chart, chart] = hoistCmp.withFactory<ChartProps>({
|
|
|
95
94
|
})
|
|
96
95
|
});
|
|
97
96
|
|
|
98
|
-
|
|
97
|
+
// Must check isMobileApp here because `useContextMenu` is not defined in mobile
|
|
98
|
+
return !XH.isMobileApp ? useContextMenu(coreContents, model.contextMenu) : coreContents;
|
|
99
99
|
}
|
|
100
100
|
});
|
|
101
101
|
|
|
@@ -106,12 +106,9 @@ class ChartLocalModel extends HoistModel {
|
|
|
106
106
|
model: ChartModel;
|
|
107
107
|
|
|
108
108
|
chartRef = createObservableRef<HTMLElement>();
|
|
109
|
-
contextMenu: any;
|
|
110
109
|
prevSeriesConfig;
|
|
111
110
|
|
|
112
111
|
override onLinked() {
|
|
113
|
-
this.contextMenu = this.getContextMenu();
|
|
114
|
-
|
|
115
112
|
this.addReaction({
|
|
116
113
|
track: () => [
|
|
117
114
|
this.componentProps.aspectRatio,
|
|
@@ -136,7 +133,7 @@ class ChartLocalModel extends HoistModel {
|
|
|
136
133
|
return this.model.highchart;
|
|
137
134
|
}
|
|
138
135
|
|
|
139
|
-
updateSeries() {
|
|
136
|
+
private updateSeries() {
|
|
140
137
|
const newSeries = this.model.series,
|
|
141
138
|
seriesConfig = newSeries.map(it => omit(it, 'data')),
|
|
142
139
|
{prevSeriesConfig, chart} = this,
|
|
@@ -156,7 +153,7 @@ class ChartLocalModel extends HoistModel {
|
|
|
156
153
|
this.prevSeriesConfig = seriesConfig;
|
|
157
154
|
}
|
|
158
155
|
|
|
159
|
-
renderHighChart() {
|
|
156
|
+
private renderHighChart() {
|
|
160
157
|
// Chart does not re-render well in fullscreen mode
|
|
161
158
|
// so just close fullscreen mode if it's open.
|
|
162
159
|
if (this.chart?.fullscreen?.isOpen) {
|
|
@@ -200,7 +197,7 @@ class ChartLocalModel extends HoistModel {
|
|
|
200
197
|
}
|
|
201
198
|
};
|
|
202
199
|
|
|
203
|
-
getChartDims({width, height}) {
|
|
200
|
+
private getChartDims({width, height}) {
|
|
204
201
|
const {aspectRatio} = this.componentProps;
|
|
205
202
|
|
|
206
203
|
if (!aspectRatio || aspectRatio <= 0) return {width, height};
|
|
@@ -208,7 +205,7 @@ class ChartLocalModel extends HoistModel {
|
|
|
208
205
|
return this.applyAspectRatio(width, height, aspectRatio);
|
|
209
206
|
}
|
|
210
207
|
|
|
211
|
-
applyAspectRatio(width, height, aspectRatio) {
|
|
208
|
+
private applyAspectRatio(width, height, aspectRatio) {
|
|
212
209
|
const adjWidth = height * aspectRatio,
|
|
213
210
|
adjHeight = width / aspectRatio;
|
|
214
211
|
|
|
@@ -236,7 +233,7 @@ class ChartLocalModel extends HoistModel {
|
|
|
236
233
|
super.destroy();
|
|
237
234
|
}
|
|
238
235
|
|
|
239
|
-
destroyHighChart() {
|
|
236
|
+
private destroyHighChart() {
|
|
240
237
|
if (this.chart) {
|
|
241
238
|
this.chart.destroy();
|
|
242
239
|
this.chart = null;
|
|
@@ -246,7 +243,7 @@ class ChartLocalModel extends HoistModel {
|
|
|
246
243
|
//----------------------
|
|
247
244
|
// Highcharts Config
|
|
248
245
|
//----------------------
|
|
249
|
-
getMergedConfig(): PlainObject {
|
|
246
|
+
private getMergedConfig(): PlainObject {
|
|
250
247
|
const propsConf = this.getModelConfig(),
|
|
251
248
|
themeConf = this.getThemeConfig(),
|
|
252
249
|
defaultConf = this.getDefaultConfig();
|
|
@@ -255,7 +252,7 @@ class ChartLocalModel extends HoistModel {
|
|
|
255
252
|
return mergeDeep(defaultConf, themeConf, propsConf);
|
|
256
253
|
}
|
|
257
254
|
|
|
258
|
-
getDefaultConfig() {
|
|
255
|
+
private getDefaultConfig() {
|
|
259
256
|
const exporting = {
|
|
260
257
|
enabled: false,
|
|
261
258
|
fallbackToExportServer: false,
|
|
@@ -329,7 +326,7 @@ class ChartLocalModel extends HoistModel {
|
|
|
329
326
|
};
|
|
330
327
|
}
|
|
331
328
|
|
|
332
|
-
mergeAxisConfigs(theme, conf) {
|
|
329
|
+
private mergeAxisConfigs(theme, conf) {
|
|
333
330
|
const axisLabels = ['x', 'y', 'z'];
|
|
334
331
|
axisLabels.forEach(lbl => {
|
|
335
332
|
const axis = lbl + 'Axis',
|
|
@@ -341,11 +338,11 @@ class ChartLocalModel extends HoistModel {
|
|
|
341
338
|
});
|
|
342
339
|
}
|
|
343
340
|
|
|
344
|
-
getDefaultAxisConfig(axis) {
|
|
341
|
+
private getDefaultAxisConfig(axis) {
|
|
345
342
|
const defaults = {
|
|
346
343
|
xAxis: {
|
|
347
344
|
// Padding is ignored by setExtremes, so we default to 0 to make things less jumpy when zooming.
|
|
348
|
-
// This is especially important when Navigator shown
|
|
345
|
+
// This is especially important when the Navigator is shown. The first reload of data can cause a surprising tiny re-zoom.
|
|
349
346
|
minPadding: 0,
|
|
350
347
|
maxPadding: 0,
|
|
351
348
|
dateTimeLabelFormats: {
|
|
@@ -365,11 +362,11 @@ class ChartLocalModel extends HoistModel {
|
|
|
365
362
|
return defaults[axis];
|
|
366
363
|
}
|
|
367
364
|
|
|
368
|
-
getThemeConfig() {
|
|
365
|
+
private getThemeConfig() {
|
|
369
366
|
return XH.darkTheme ? cloneDeep(DarkTheme) : cloneDeep(LightTheme);
|
|
370
367
|
}
|
|
371
368
|
|
|
372
|
-
getModelConfig() {
|
|
369
|
+
private getModelConfig() {
|
|
373
370
|
return {
|
|
374
371
|
...this.model.highchartsConfig,
|
|
375
372
|
series: this.model.series
|
|
@@ -380,43 +377,4 @@ class ChartLocalModel extends HoistModel {
|
|
|
380
377
|
// Handlers
|
|
381
378
|
//---------------------------
|
|
382
379
|
onSetExtremes = () => {};
|
|
383
|
-
|
|
384
|
-
getContextMenu() {
|
|
385
|
-
if (!this.model.showContextMenu || !XH.isDesktop) return null;
|
|
386
|
-
return [
|
|
387
|
-
{
|
|
388
|
-
text: 'View in full screen',
|
|
389
|
-
icon: Icon.expand(),
|
|
390
|
-
actionFn: () => this.chart.fullscreen.toggle()
|
|
391
|
-
},
|
|
392
|
-
'-',
|
|
393
|
-
{
|
|
394
|
-
text: 'Copy to clipboard',
|
|
395
|
-
icon: Icon.copy(),
|
|
396
|
-
hidden: !Highcharts.isWebKit,
|
|
397
|
-
actionFn: () => this.chart.copyToClipboardAsync()
|
|
398
|
-
},
|
|
399
|
-
{
|
|
400
|
-
text: 'Print chart',
|
|
401
|
-
icon: Icon.print(),
|
|
402
|
-
actionFn: () => this.chart.print()
|
|
403
|
-
},
|
|
404
|
-
'-',
|
|
405
|
-
{
|
|
406
|
-
text: 'Download PNG image',
|
|
407
|
-
icon: Icon.fileImage(),
|
|
408
|
-
actionFn: () => this.chart.exportChartLocal()
|
|
409
|
-
},
|
|
410
|
-
{
|
|
411
|
-
text: 'Download SVG vector image',
|
|
412
|
-
icon: Icon.fileImage(),
|
|
413
|
-
actionFn: () => this.chart.exportChartLocal({type: 'image/svg+xml'})
|
|
414
|
-
},
|
|
415
|
-
{
|
|
416
|
-
text: 'Export Data',
|
|
417
|
-
icon: Icon.fileCsv(),
|
|
418
|
-
actionFn: () => this.chart.downloadCSV()
|
|
419
|
-
}
|
|
420
|
-
];
|
|
421
|
-
}
|
|
422
380
|
}
|
package/cmp/chart/ChartModel.ts
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Copyright © 2025 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import {type MouseEvent} from 'react';
|
|
8
|
+
import type {ChartContextMenuSpec, ChartMenuToken} from '@xh/hoist/cmp/chart/Types';
|
|
9
|
+
import {getContextMenuItems} from '@xh/hoist/cmp/chart/impl/ChartContextMenuItems';
|
|
10
|
+
import {HoistModel, PlainObject, Some, XH} from '@xh/hoist/core';
|
|
8
11
|
import {action, makeObservable, observable} from '@xh/hoist/mobx';
|
|
9
|
-
import {castArray, cloneDeep} from 'lodash';
|
|
12
|
+
import {castArray, cloneDeep, isFunction, isNil} from 'lodash';
|
|
10
13
|
import {mergeDeep} from '@xh/hoist/utils/js';
|
|
11
14
|
|
|
12
15
|
interface ChartConfig {
|
|
@@ -16,8 +19,10 @@ interface ChartConfig {
|
|
|
16
19
|
/** The initial data series to be displayed. */
|
|
17
20
|
series?: Some<any>;
|
|
18
21
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
22
|
+
/**
|
|
23
|
+
* True (default) to show default ContextMenu. Supported on desktop only.
|
|
24
|
+
*/
|
|
25
|
+
contextMenu?: ChartContextMenuSpec;
|
|
21
26
|
|
|
22
27
|
/** @internal */
|
|
23
28
|
xhImpl?: boolean;
|
|
@@ -33,7 +38,18 @@ export class ChartModel extends HoistModel {
|
|
|
33
38
|
@observable.ref
|
|
34
39
|
series: any[] = [];
|
|
35
40
|
|
|
36
|
-
|
|
41
|
+
contextMenu: ChartContextMenuSpec;
|
|
42
|
+
|
|
43
|
+
static defaultContextMenu: ChartMenuToken[] = [
|
|
44
|
+
'viewFullscreen',
|
|
45
|
+
'-',
|
|
46
|
+
'copyToClipboard',
|
|
47
|
+
'printChart',
|
|
48
|
+
'-',
|
|
49
|
+
'downloadPNG',
|
|
50
|
+
'downloadSVG',
|
|
51
|
+
'downloadCSV'
|
|
52
|
+
];
|
|
37
53
|
|
|
38
54
|
/**
|
|
39
55
|
* The HighCharts instance currently being displayed. This may be used for reading
|
|
@@ -47,17 +63,12 @@ export class ChartModel extends HoistModel {
|
|
|
47
63
|
super();
|
|
48
64
|
makeObservable(this);
|
|
49
65
|
|
|
50
|
-
const {
|
|
51
|
-
highchartsConfig,
|
|
52
|
-
series = [],
|
|
53
|
-
showContextMenu = true,
|
|
54
|
-
xhImpl = false
|
|
55
|
-
} = config ?? {};
|
|
66
|
+
const {highchartsConfig, series = [], contextMenu, xhImpl = false} = config ?? {};
|
|
56
67
|
|
|
57
68
|
this.xhImpl = xhImpl;
|
|
58
69
|
this.highchartsConfig = highchartsConfig;
|
|
59
70
|
this.series = castArray(series);
|
|
60
|
-
this.
|
|
71
|
+
this.contextMenu = this.parseContextMenu(contextMenu);
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
/**
|
|
@@ -95,4 +106,25 @@ export class ChartModel extends HoistModel {
|
|
|
95
106
|
clear() {
|
|
96
107
|
this.setSeries([]);
|
|
97
108
|
}
|
|
109
|
+
|
|
110
|
+
private parseContextMenu(spec: ChartContextMenuSpec): ChartContextMenuSpec {
|
|
111
|
+
if (spec === false || !XH.isDesktop) return null;
|
|
112
|
+
if (isNil(spec) || spec === true) spec = ChartModel.defaultContextMenu;
|
|
113
|
+
|
|
114
|
+
return (e: MouseEvent | PointerEvent) => {
|
|
115
|
+
// Convert hoverpoints to points for use in actionFn.
|
|
116
|
+
// Hoverpoints are transient, and change/disappear as mouse moves.
|
|
117
|
+
const getPoint = pt => pt.series?.points.find(it => it.index === pt.index);
|
|
118
|
+
const {hoverPoint, hoverPoints} = this.highchart,
|
|
119
|
+
context = {
|
|
120
|
+
contextMenuEvent: e,
|
|
121
|
+
chartModel: this,
|
|
122
|
+
point: hoverPoint ? getPoint(hoverPoint) : null,
|
|
123
|
+
points: hoverPoints ? hoverPoints.map(getPoint) : []
|
|
124
|
+
},
|
|
125
|
+
items = isFunction(spec) ? spec(e, context) : spec;
|
|
126
|
+
|
|
127
|
+
return getContextMenuItems(items, context);
|
|
128
|
+
};
|
|
129
|
+
}
|
|
98
130
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {ChartModel} from '@xh/hoist/cmp/chart/ChartModel';
|
|
2
|
+
import type {ContextMenuSpec, MenuContext, MenuToken} from '@xh/hoist/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Highcharts supported tokens {@link https://api.highcharts.com/highcharts/exporting.buttons.contextButton.menuItems}
|
|
6
|
+
* plus Hoist's `copyToClipboard`.
|
|
7
|
+
*/
|
|
8
|
+
export type ChartMenuToken =
|
|
9
|
+
| 'viewFullscreen'
|
|
10
|
+
| 'printChart'
|
|
11
|
+
| 'downloadJPEG'
|
|
12
|
+
| 'downloadPNG'
|
|
13
|
+
| 'downloadSVG'
|
|
14
|
+
| 'downloadCSV'
|
|
15
|
+
| 'downloadXLS'
|
|
16
|
+
| 'downloadPDF'
|
|
17
|
+
| 'copyToClipboard'
|
|
18
|
+
| MenuToken;
|
|
19
|
+
|
|
20
|
+
export interface ChartMenuContext extends MenuContext {
|
|
21
|
+
chartModel: ChartModel;
|
|
22
|
+
/**
|
|
23
|
+
* Single point is the active series point the mouse is closest to
|
|
24
|
+
*/
|
|
25
|
+
point: any;
|
|
26
|
+
/**
|
|
27
|
+
* Points array is the list of points hovered over in each series. When
|
|
28
|
+
* there are multiple series and tooltip.shared = true, points.length less than 1.
|
|
29
|
+
*/
|
|
30
|
+
points: any[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ChartContextMenuSpec = ContextMenuSpec<ChartMenuToken, ChartMenuContext>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file belongs to Hoist, an application development toolkit
|
|
3
|
+
* developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
|
|
4
|
+
*
|
|
5
|
+
* Copyright © 2025 Extremely Heavy Industries Inc.
|
|
6
|
+
*/
|
|
7
|
+
import type {ChartMenuContext, ChartMenuToken} from '@xh/hoist/cmp/chart/Types';
|
|
8
|
+
import {logWarn} from '@xh/hoist/utils/js';
|
|
9
|
+
import {cloneDeep, isEmpty, isString} from 'lodash';
|
|
10
|
+
import {ChartModel} from '@xh/hoist/cmp/chart';
|
|
11
|
+
import {isMenuItem, type MenuItem, type MenuItemLike} from '@xh/hoist/core';
|
|
12
|
+
import {Highcharts} from '@xh/hoist/kit/highcharts';
|
|
13
|
+
import {Icon} from '@xh/hoist/icon';
|
|
14
|
+
|
|
15
|
+
/** @internal */
|
|
16
|
+
export function getContextMenuItems(
|
|
17
|
+
items: MenuItemLike<ChartMenuToken>[],
|
|
18
|
+
context: ChartMenuContext
|
|
19
|
+
): (MenuItem<ChartMenuToken, ChartMenuContext> | '-')[] {
|
|
20
|
+
return cloneDeep(items).map(it => buildMenuItemConfig(it, context));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
//---------------------------
|
|
24
|
+
// Implementation
|
|
25
|
+
//---------------------------
|
|
26
|
+
function buildMenuItemConfig(
|
|
27
|
+
item: MenuItemLike<ChartMenuToken, ChartMenuContext>,
|
|
28
|
+
context: ChartMenuContext
|
|
29
|
+
): MenuItem<ChartMenuToken, ChartMenuContext> | '-' {
|
|
30
|
+
if (isString(item)) return parseToken(item, context.chartModel);
|
|
31
|
+
|
|
32
|
+
// build nested menu item configs
|
|
33
|
+
if (isMenuItem(item)) {
|
|
34
|
+
if (!isEmpty(item.items)) {
|
|
35
|
+
(item.items as (MenuItem<ChartMenuToken, ChartMenuContext> | '-')[]) = item.items.map(
|
|
36
|
+
it => buildMenuItemConfig(it as MenuItemLike, context)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (item.actionFn) {
|
|
40
|
+
const fn = item.actionFn;
|
|
41
|
+
item.actionFn = e => fn(e, context);
|
|
42
|
+
}
|
|
43
|
+
if (item.prepareFn) {
|
|
44
|
+
const fn = item.prepareFn;
|
|
45
|
+
item.prepareFn = item => fn(item, context);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return item as MenuItem<ChartMenuToken, ChartMenuContext>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseToken(
|
|
53
|
+
token: string,
|
|
54
|
+
chartModel: ChartModel
|
|
55
|
+
): MenuItem<ChartMenuToken, ChartMenuContext> | '-' {
|
|
56
|
+
switch (token) {
|
|
57
|
+
case 'viewFullscreen':
|
|
58
|
+
return {
|
|
59
|
+
text: 'View in full screen',
|
|
60
|
+
icon: Icon.expand(),
|
|
61
|
+
actionFn: () => chartModel.highchart.fullscreen.toggle()
|
|
62
|
+
};
|
|
63
|
+
case 'copyToClipboard':
|
|
64
|
+
return {
|
|
65
|
+
text: 'Copy to clipboard',
|
|
66
|
+
icon: Icon.copy(),
|
|
67
|
+
hidden: !Highcharts.isWebKit,
|
|
68
|
+
actionFn: () => chartModel.highchart.copyToClipboardAsync()
|
|
69
|
+
};
|
|
70
|
+
case 'printChart':
|
|
71
|
+
return {
|
|
72
|
+
text: 'Print chart',
|
|
73
|
+
icon: Icon.print(),
|
|
74
|
+
actionFn: () => chartModel.highchart.print()
|
|
75
|
+
};
|
|
76
|
+
case 'downloadJPEG':
|
|
77
|
+
return {
|
|
78
|
+
text: 'Download JPEG image',
|
|
79
|
+
icon: Icon.fileImage(),
|
|
80
|
+
actionFn: () =>
|
|
81
|
+
chartModel.highchart.exportChartLocal({
|
|
82
|
+
type: 'image/jpeg'
|
|
83
|
+
})
|
|
84
|
+
};
|
|
85
|
+
case 'downloadPNG':
|
|
86
|
+
return {
|
|
87
|
+
text: 'Download PNG image',
|
|
88
|
+
icon: Icon.fileImage(),
|
|
89
|
+
actionFn: () => chartModel.highchart.exportChartLocal()
|
|
90
|
+
};
|
|
91
|
+
case 'downloadSVG':
|
|
92
|
+
return {
|
|
93
|
+
text: 'Download SVG vector image',
|
|
94
|
+
icon: Icon.fileImage(),
|
|
95
|
+
actionFn: () =>
|
|
96
|
+
chartModel.highchart.exportChartLocal({
|
|
97
|
+
type: 'image/svg+xml'
|
|
98
|
+
})
|
|
99
|
+
};
|
|
100
|
+
case 'downloadPDF':
|
|
101
|
+
return {
|
|
102
|
+
text: 'Download PDF',
|
|
103
|
+
icon: Icon.fileImage(),
|
|
104
|
+
actionFn: () =>
|
|
105
|
+
chartModel.highchart.exportChartLocal({
|
|
106
|
+
type: 'application/pdf'
|
|
107
|
+
})
|
|
108
|
+
};
|
|
109
|
+
case 'downloadCSV':
|
|
110
|
+
return {
|
|
111
|
+
text: 'Download CSV',
|
|
112
|
+
icon: Icon.fileCsv(),
|
|
113
|
+
actionFn: () => chartModel.highchart.downloadCSV()
|
|
114
|
+
};
|
|
115
|
+
case 'downloadXLS':
|
|
116
|
+
return {
|
|
117
|
+
text: 'Download Excel',
|
|
118
|
+
icon: Icon.fileExcel(),
|
|
119
|
+
actionFn: () => chartModel.highchart.downloadXLS()
|
|
120
|
+
};
|
|
121
|
+
case '-':
|
|
122
|
+
return '-';
|
|
123
|
+
default:
|
|
124
|
+
logWarn(
|
|
125
|
+
`Invalid ChartMenuToken "${token}" will not be used.`,
|
|
126
|
+
'ChartContextMenuItem.ts:parseToken'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -30,7 +30,8 @@ export function installZoomoutGesture(Highcharts) {
|
|
|
30
30
|
|
|
31
31
|
Highcharts.addEvent(this, 'selection', e => {
|
|
32
32
|
if (pixelDiff < 0) {
|
|
33
|
-
|
|
33
|
+
// `undefined`'s preserve defaults, last arg `false` disables animation
|
|
34
|
+
this.xAxis?.forEach(it => it.setExtremes(undefined, undefined, undefined, false));
|
|
34
35
|
e.preventDefault();
|
|
35
36
|
}
|
|
36
37
|
});
|