@gravity-ui/charts 1.40.0 → 1.41.0

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/README.md CHANGED
@@ -1,31 +1,11 @@
1
- # Gravity UI Charts
1
+ # Gravity UI Charts · [![npm package](https://img.shields.io/npm/v/@gravity-ui/charts)](https://www.npmjs.com/package/@gravity-ui/charts) [![License](https://img.shields.io/github/license/gravity-ui/charts)](LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/gravity-ui/charts/.github/workflows/ci.yml?label=CI&logo=github)](https://github.com/gravity-ui/charts/actions/workflows/ci.yml?query=branch:main) [![storybook](https://img.shields.io/badge/Storybook-deployed-ff4685)](https://preview.gravity-ui.com/charts/)
2
2
 
3
- A flexible JavaScript library for data visualization and chart rendering using React.
4
-
5
- [![npm package](https://img.shields.io/npm/v/@gravity-ui/charts)](https://www.npmjs.com/package/@gravity-ui/charts) [![CI](https://img.shields.io/github/actions/workflow/status/gravity-ui/charts/.github/workflows/ci.yml?label=CI&logo=github)](https://github.com/gravity-ui/charts/actions/workflows/ci.yml?query=branch:main) [![storybook](https://img.shields.io/badge/Storybook-deployed-ff4685)](https://preview.gravity-ui.com/charts/)
3
+ React charting library with 10+ chart types: area, bar, line, pie, scatter, treemap, and more.
6
4
 
7
5
  ## Documentation
8
6
 
9
7
  - [Overview](https://gravity-ui.github.io/charts/pages/overview.html)
8
+ - [Get started](https://gravity-ui.github.io/charts/pages/get-started.html)
9
+ - [Development](https://gravity-ui.github.io/charts/pages/development.html)
10
10
  - [API](https://gravity-ui.github.io/charts/pages/api/overview.html)
11
11
  - [Guides](https://gravity-ui.github.io/charts/pages/guides/tooltip.html)
12
-
13
- ## Get started
14
-
15
- ### Install
16
-
17
- ```shell
18
- npm install @gravity-ui/uikit @gravity-ui/charts
19
- ```
20
-
21
- ### Development
22
-
23
- To start the development server with storybook run the following:
24
-
25
- ```shell
26
- npm run start
27
- ```
28
-
29
- ## Contributing
30
-
31
- Please refer to the [contributing document](https://github.com/gravity-ui/charts/blob/main/CONTRIBUTING.md) if you wish to make pull requests.
@@ -12,8 +12,8 @@ export function Row(props) {
12
12
  }
13
13
  return null;
14
14
  }, [color, colorSymbol]);
15
- return (React.createElement("div", { className: b('content-row', { active, striped }, className), style: style },
16
- colorItem,
17
- React.createElement("span", { className: b('content-row-label') }, label),
18
- value && React.createElement("span", { className: b('content-row-value') }, value)));
15
+ return (React.createElement("tr", { className: b('content-row', { active, striped }, className), style: style },
16
+ colorItem && React.createElement("td", { className: b('content-row-color-cell') }, colorItem),
17
+ React.createElement("td", { className: b('content-row-label-cell') }, label),
18
+ value && React.createElement("td", { className: b('content-row-value-cell') }, value)));
19
19
  }
@@ -1,7 +1,6 @@
1
1
  import React from 'react';
2
2
  import { block } from '../../../utils';
3
3
  import { getFormattedValue } from '../../../utils/chart/format';
4
- import { Row } from './Row';
5
4
  import { getBuiltInAggregatedValue, getBuiltInAggregationLabel } from './utils';
6
5
  const b = block('tooltip');
7
6
  export function RowWithAggregation(props) {
@@ -19,5 +18,7 @@ export function RowWithAggregation(props) {
19
18
  format: valueFormat || { type: 'number' },
20
19
  })
21
20
  : resultValue;
22
- return (React.createElement(Row, { className: b('content-row-totals'), label: resultLabel, style: style, value: formattedResultValue }));
21
+ return (React.createElement("div", { className: b('content-row', { totals: true }), style: style },
22
+ React.createElement("span", { className: b('content-row-totals-label') }, resultLabel),
23
+ formattedResultValue && (React.createElement("span", { className: b('content-row-totals-value') }, formattedResultValue))));
23
24
  }
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { Divider } from '@gravity-ui/uikit';
3
+ import parse from 'html-react-parser';
3
4
  import get from 'lodash/get';
4
5
  import isEqual from 'lodash/isEqual';
5
6
  import { usePrevious } from '../../../hooks';
@@ -35,7 +36,7 @@ export const DefaultTooltipContent = ({ hovered, pinned, rowRenderer, totals, va
35
36
  hovered,
36
37
  });
37
38
  if (typeof result === 'string') {
38
- return React.createElement("div", { key: id, dangerouslySetInnerHTML: { __html: result } });
39
+ return React.createElement(React.Fragment, { key: id }, parse(result));
39
40
  }
40
41
  return result;
41
42
  }
@@ -75,127 +76,136 @@ export const DefaultTooltipContent = ({ hovered, pinned, rowRenderer, totals, va
75
76
  setScrollBarWidth(0);
76
77
  }
77
78
  }, [pinned]);
79
+ const rowsContent = (React.createElement(React.Fragment, null, visibleHovered.map((seriesItem, i) => {
80
+ var _a;
81
+ const { data, series, closest } = seriesItem;
82
+ const id = `${get(series, 'id')}_${i}`;
83
+ const color = get(data, 'color') || get(series, 'color');
84
+ // TODO: improve active item display https://github.com/gravity-ui/charts/issues/208
85
+ const active = closest && hovered.length > 1;
86
+ const striped = (i + 1) % 2 === 0;
87
+ const rowValueFormat = get(series, 'tooltip.valueFormat', valueFormat);
88
+ switch (series.type) {
89
+ case 'scatter':
90
+ case 'line':
91
+ case 'area':
92
+ case 'bar-x': {
93
+ const format = rowValueFormat || getDefaultValueFormat({ axis: yAxis });
94
+ const formattedValue = getFormattedValue({
95
+ value: hoveredValues[i],
96
+ format,
97
+ });
98
+ return renderRow({
99
+ id,
100
+ active,
101
+ color,
102
+ name: series.name,
103
+ striped,
104
+ value: hoveredValues[i],
105
+ formattedValue,
106
+ series,
107
+ });
108
+ }
109
+ case 'waterfall': {
110
+ const isTotal = get(data, 'total', false);
111
+ const subTotalValue = (_a = seriesItem.subTotal) !== null && _a !== void 0 ? _a : 0;
112
+ const format = rowValueFormat || getDefaultValueFormat({ axis: yAxis });
113
+ const subTotal = getFormattedValue({
114
+ value: subTotalValue,
115
+ format,
116
+ });
117
+ const formattedValue = getFormattedValue({
118
+ value: hoveredValues[i],
119
+ format,
120
+ });
121
+ return (React.createElement(React.Fragment, { key: id },
122
+ !isTotal && (React.createElement(React.Fragment, null,
123
+ React.createElement("div", { className: b('series-name') }, getXRowData(data, xAxis)),
124
+ React.createElement(Row, { label: series.name, value: formattedValue }))),
125
+ React.createElement(Row, { label: isTotal ? 'Total' : 'Subtotal', value: subTotal })));
126
+ }
127
+ case 'bar-y': {
128
+ const format = rowValueFormat || getDefaultValueFormat({ axis: xAxis });
129
+ const formattedValue = getFormattedValue({
130
+ value: hoveredValues[i],
131
+ format,
132
+ });
133
+ return renderRow({
134
+ id,
135
+ active,
136
+ color,
137
+ name: series.name,
138
+ striped,
139
+ value: hoveredValues[i],
140
+ formattedValue,
141
+ });
142
+ }
143
+ case 'pie':
144
+ case 'heatmap':
145
+ case 'treemap':
146
+ case 'funnel': {
147
+ const seriesData = data;
148
+ const formattedValue = getFormattedValue({
149
+ value: hoveredValues[i],
150
+ format: rowValueFormat || { type: 'number' },
151
+ });
152
+ return renderRow({
153
+ id,
154
+ color,
155
+ name: [seriesData.name || seriesData.id].flat().join('\n'),
156
+ value: hoveredValues[i],
157
+ formattedValue,
158
+ });
159
+ }
160
+ case 'sankey': {
161
+ const { target, data: source } = seriesItem;
162
+ const formattedValue = getFormattedValue({
163
+ value: hoveredValues[i],
164
+ format: rowValueFormat || { type: 'number' },
165
+ });
166
+ return renderRow({
167
+ id,
168
+ color,
169
+ name: `${source.name} → ${target === null || target === void 0 ? void 0 : target.name}`,
170
+ value: hoveredValues[i],
171
+ formattedValue,
172
+ });
173
+ }
174
+ case 'radar': {
175
+ const radarSeries = series;
176
+ const formattedValue = getFormattedValue({
177
+ value: hoveredValues[i],
178
+ format: rowValueFormat || { type: 'number' },
179
+ });
180
+ return renderRow({
181
+ id,
182
+ color,
183
+ active,
184
+ name: radarSeries.name || radarSeries.id,
185
+ value: hoveredValues[i],
186
+ formattedValue,
187
+ });
188
+ }
189
+ default: {
190
+ return null;
191
+ }
192
+ }
193
+ })));
78
194
  return (React.createElement("div", { className: b('content'), "data-qa": qa },
79
195
  formattedHeadValue && (React.createElement("div", { className: b('series-name') },
80
196
  React.createElement("div", { className: b('series-name-text'), dangerouslySetInnerHTML: { __html: formattedHeadValue } }))),
81
197
  React.createElement("div", { className: b('content-rows', { pinned }), ref: contentRowsRef, style: pinned ? { maxHeight: maxContentRowsHeight } : undefined },
82
- visibleHovered.map((seriesItem, i) => {
83
- var _a;
84
- const { data, series, closest } = seriesItem;
85
- const id = `${get(series, 'id')}_${i}`;
86
- const color = get(data, 'color') || get(series, 'color');
87
- // TODO: improve action item display https://github.com/gravity-ui/charts/issues/208
88
- const active = closest && hovered.length > 1;
89
- const striped = (i + 1) % 2 === 0;
90
- const rowValueFormat = get(series, 'tooltip.valueFormat', valueFormat);
91
- switch (series.type) {
92
- case 'scatter':
93
- case 'line':
94
- case 'area':
95
- case 'bar-x': {
96
- const format = rowValueFormat || getDefaultValueFormat({ axis: yAxis });
97
- const formattedValue = getFormattedValue({
98
- value: hoveredValues[i],
99
- format,
100
- });
101
- return renderRow({
102
- id,
103
- active,
104
- color,
105
- name: series.name,
106
- striped,
107
- value: hoveredValues[i],
108
- formattedValue,
109
- series,
110
- });
111
- }
112
- case 'waterfall': {
113
- const isTotal = get(data, 'total', false);
114
- const subTotalValue = (_a = seriesItem.subTotal) !== null && _a !== void 0 ? _a : 0;
115
- const format = rowValueFormat || getDefaultValueFormat({ axis: yAxis });
116
- const subTotal = getFormattedValue({
117
- value: subTotalValue,
118
- format,
119
- });
120
- const formattedValue = getFormattedValue({
121
- value: hoveredValues[i],
122
- format,
123
- });
124
- return (React.createElement(React.Fragment, { key: id },
125
- !isTotal && (React.createElement(React.Fragment, null,
126
- React.createElement("div", { className: b('series-name') }, getXRowData(data, xAxis)),
127
- React.createElement(Row, { label: series.name, value: formattedValue }))),
128
- React.createElement(Row, { label: isTotal ? 'Total' : 'Subtotal', value: subTotal })));
129
- }
130
- case 'bar-y': {
131
- const format = rowValueFormat || getDefaultValueFormat({ axis: xAxis });
132
- const formattedValue = getFormattedValue({
133
- value: hoveredValues[i],
134
- format,
135
- });
136
- return renderRow({
137
- id,
138
- active,
139
- color,
140
- name: series.name,
141
- striped,
142
- value: hoveredValues[i],
143
- formattedValue,
144
- });
145
- }
146
- case 'pie':
147
- case 'heatmap':
148
- case 'treemap':
149
- case 'funnel': {
150
- const seriesData = data;
151
- const formattedValue = getFormattedValue({
152
- value: hoveredValues[i],
153
- format: rowValueFormat || { type: 'number' },
154
- });
155
- return renderRow({
156
- id,
157
- color,
158
- name: [seriesData.name || seriesData.id].flat().join('\n'),
159
- value: hoveredValues[i],
160
- formattedValue,
161
- });
162
- }
163
- case 'sankey': {
164
- const { target, data: source } = seriesItem;
165
- const formattedValue = getFormattedValue({
166
- value: hoveredValues[i],
167
- format: rowValueFormat || { type: 'number' },
168
- });
169
- return renderRow({
170
- id,
171
- color,
172
- name: `${source.name} → ${target === null || target === void 0 ? void 0 : target.name}`,
173
- value: hoveredValues[i],
174
- formattedValue,
175
- });
176
- }
177
- case 'radar': {
178
- const radarSeries = series;
179
- const formattedValue = getFormattedValue({
180
- value: hoveredValues[i],
181
- format: rowValueFormat || { type: 'number' },
182
- });
183
- return renderRow({
184
- id,
185
- color,
186
- active,
187
- name: radarSeries.name || radarSeries.id,
188
- value: hoveredValues[i],
189
- formattedValue,
190
- });
191
- }
192
- default: {
193
- return null;
194
- }
195
- }
196
- }),
197
- Boolean(restHoveredValues.length) && (React.createElement(Row, { label: i18n('tooltip', 'label_more', { count: restHoveredValues.length }), striped: (visibleHovered.length + 1) % 2 === 0 }))),
198
+ React.createElement("table", { className: b('content-rows-table') },
199
+ React.createElement("tbody", null, rowsContent))),
200
+ Boolean(restHoveredValues.length) && (React.createElement("div", { className: b('content-row', {
201
+ striped: (visibleHovered.length + 1) % 2 === 0,
202
+ }) }, i18n('tooltip', 'label_more', { count: restHoveredValues.length }))),
198
203
  (totals === null || totals === void 0 ? void 0 : totals.enabled) && hovered.length > 1 && (React.createElement(React.Fragment, null,
199
204
  React.createElement(Divider, { className: b('content-row-totals-divider') }),
200
- React.createElement(RowWithAggregation, { aggregation: getPreparedAggregation({ hovered, totals, xAxis, yAxis }), label: totals.label, style: { marginRight: scrollBarWidth }, values: hoveredValues, valueFormat: (_a = totals.valueFormat) !== null && _a !== void 0 ? _a : valueFormat })))));
205
+ React.createElement(RowWithAggregation, { aggregation: getPreparedAggregation({
206
+ hovered,
207
+ totals,
208
+ xAxis,
209
+ yAxis,
210
+ }), label: totals.label, style: { marginRight: scrollBarWidth }, values: hoveredValues, valueFormat: (_a = totals.valueFormat) !== null && _a !== void 0 ? _a : valueFormat })))));
201
211
  };
@@ -3,8 +3,12 @@
3
3
  background-color: var(--g-color-infographics-tooltip-bg);
4
4
  box-shadow: 0 2px 12px var(--g-color-sfx-shadow);
5
5
  }
6
+ tr.gcharts-tooltip__content-row {
7
+ display: table-row;
8
+ padding: 0;
9
+ }
10
+
6
11
  .gcharts-tooltip__popup-content {
7
- max-width: 450px;
8
12
  text-wrap: nowrap;
9
13
  border-radius: 4px;
10
14
  }
@@ -20,7 +24,13 @@
20
24
  .gcharts-tooltip__content-rows_pinned {
21
25
  overflow: auto;
22
26
  }
27
+ .gcharts-tooltip__content-rows-table {
28
+ width: 100%;
29
+ padding: 0;
30
+ border-collapse: collapse;
31
+ }
23
32
  .gcharts-tooltip__series-name {
33
+ max-width: 450px;
24
34
  padding: 2px 14px 6px;
25
35
  font-size: 13px;
26
36
  font-weight: 600;
@@ -45,8 +55,23 @@
45
55
  font-weight: 600;
46
56
  background-color: var(--g-color-base-info-medium);
47
57
  }
48
- .gcharts-tooltip__content-row-label {
58
+ .gcharts-tooltip__content-row_totals {
59
+ color: var(--g-color-text-complementary);
60
+ }
61
+ .gcharts-tooltip__content-row-totals-label {
49
62
  overflow: hidden;
63
+ max-width: 400px;
64
+ white-space: nowrap;
65
+ text-overflow: ellipsis;
66
+ }
67
+ .gcharts-tooltip__content-row-totals-value {
68
+ flex-shrink: 0;
69
+ margin-inline-start: auto;
70
+ }
71
+ .gcharts-tooltip__content-row-label-cell {
72
+ overflow: hidden;
73
+ max-width: 400px;
74
+ padding: 2px 4px;
50
75
  white-space: nowrap;
51
76
  text-overflow: ellipsis;
52
77
  }
@@ -58,12 +83,13 @@
58
83
  border-radius: 2px;
59
84
  background-color: #dddddd;
60
85
  }
61
- .gcharts-tooltip__content-row-value {
62
- flex-shrink: 0;
63
- margin-inline-start: auto;
86
+ .gcharts-tooltip__content-row-color-cell {
87
+ width: 16px;
88
+ padding: 2px 4px 2px 14px;
64
89
  }
65
- .gcharts-tooltip__content-row-totals {
66
- color: var(--g-color-text-complementary);
90
+ .gcharts-tooltip__content-row-value-cell {
91
+ padding: 2px 14px 2px 4px;
92
+ text-align: end;
67
93
  }
68
94
  .gcharts-tooltip__content-row-totals-divider {
69
95
  margin-block: 5px 5px;
@@ -1,8 +1,11 @@
1
1
  import React from 'react';
2
2
  import type { ChartData } from '../types';
3
3
  export * from './Tooltip/ChartTooltipContent';
4
+ export interface ChartReflowOptions {
5
+ immediate?: boolean;
6
+ }
4
7
  export interface ChartRef {
5
- reflow: () => void;
8
+ reflow: (options?: ChartReflowOptions) => void;
6
9
  }
7
10
  export interface ChartDimentions {
8
11
  height: number;
@@ -30,10 +30,15 @@ export const Chart = React.forwardRef(function Chart(props, forwardedRef) {
30
30
  return debounced.current;
31
31
  }, [handleResize]);
32
32
  React.useImperativeHandle(forwardedRef, () => ({
33
- reflow() {
34
- debuncedHandleResize();
33
+ reflow(options) {
34
+ if (options === null || options === void 0 ? void 0 : options.immediate) {
35
+ handleResize();
36
+ }
37
+ else {
38
+ debuncedHandleResize();
39
+ }
35
40
  },
36
- }), [debuncedHandleResize]);
41
+ }), [debuncedHandleResize, handleResize]);
37
42
  React.useEffect(() => {
38
43
  // dimensions initialize
39
44
  handleResize();
@@ -162,6 +162,7 @@ export const prepareBarXData = async (args) => {
162
162
  : yAxisTop + base + negativeStackHeight,
163
163
  width: rectWidth,
164
164
  height: shapeHeight,
165
+ _height: height,
165
166
  opacity: get(yValue.data, 'opacity', null),
166
167
  data: yValue.data,
167
168
  series: yValue.series,
@@ -178,9 +179,9 @@ export const prepareBarXData = async (args) => {
178
179
  }
179
180
  if (series.some((s) => s.stacking === 'percent')) {
180
181
  let acc = 0;
181
- const ratio = plotHeight / (positiveStackHeight - (stackItems.length - 1) * stackGap);
182
+ const ratio = plotHeight / positiveStackHeight;
182
183
  stackItems.forEach((item) => {
183
- item.height = item.height * ratio;
184
+ item.height = item._height * ratio;
184
185
  item.y = plotHeight - item.height - acc;
185
186
  acc += item.height + 1;
186
187
  });
@@ -10,4 +10,9 @@ export type PreparedBarXData = Omit<TooltipDataChunkBarX, 'series'> & {
10
10
  label?: LabelData;
11
11
  htmlElements: HtmlItem[];
12
12
  isLastStackItem: boolean;
13
+ /**
14
+ * the utility field for storing the original height (for recalculations, etc.)
15
+ * should not be used for displaying
16
+ */
17
+ _height: number;
13
18
  };
@@ -113,6 +113,10 @@ export type ChartTooltipRowRendererArgs = {
113
113
  value: string | number | null | undefined;
114
114
  formattedValue?: string;
115
115
  hovered?: TooltipDataChunk<unknown>[];
116
+ /**
117
+ * CSS class name pre-built with active/striped modifiers.
118
+ * Apply it to the root `<tr>` element of the returned row: `<tr className={className}>`.
119
+ */
116
120
  className?: string;
117
121
  };
118
122
  export type ChartTooltipSortComparator<T = MeaningfulAny> = (a: TooltipDataChunk<T>, b: TooltipDataChunk<T>) => number;
@@ -123,7 +127,26 @@ export interface ChartTooltip<T = MeaningfulAny> {
123
127
  /**
124
128
  * Defines the way a single data/series is displayed (corresponding to a separate selected point/ruler/shape on the chart).
125
129
  * It is useful in cases where you need to display additional information, but keep the general format of the tooltip.
126
- * If a string is returned, it will be interpreted as raw HTML and inserted without escaping.
130
+ *
131
+ * The returned React element must be a `<tr>` so that it fits into the table layout used by the tooltip.
132
+ * Apply the `className` arg to the root `<tr>` to get the correct active/striped styles.
133
+ *
134
+ * If a string is returned, it will be parsed as HTML and rendered as-is — the string must be a complete
135
+ * `<tr>...</tr>` element.
136
+ * @example React element
137
+ * ```tsx
138
+ * rowRenderer: ({id, name, value, className}) => (
139
+ * <tr key={id} className={className}>
140
+ * <td>{name}</td>
141
+ * <td>{value}</td>
142
+ * </tr>
143
+ * )
144
+ * ```
145
+ * @example Raw HTML string
146
+ * ```ts
147
+ * rowRenderer: ({name, value, className}) =>
148
+ * `<tr class="${className}"><td>${name}</td><td>${value}</td></tr>`
149
+ * ```
127
150
  */
128
151
  rowRenderer?: ((args: ChartTooltipRowRendererArgs) => React.ReactElement | string) | null;
129
152
  pin?: {
@@ -12,8 +12,8 @@ export function Row(props) {
12
12
  }
13
13
  return null;
14
14
  }, [color, colorSymbol]);
15
- return (React.createElement("div", { className: b('content-row', { active, striped }, className), style: style },
16
- colorItem,
17
- React.createElement("span", { className: b('content-row-label') }, label),
18
- value && React.createElement("span", { className: b('content-row-value') }, value)));
15
+ return (React.createElement("tr", { className: b('content-row', { active, striped }, className), style: style },
16
+ colorItem && React.createElement("td", { className: b('content-row-color-cell') }, colorItem),
17
+ React.createElement("td", { className: b('content-row-label-cell') }, label),
18
+ value && React.createElement("td", { className: b('content-row-value-cell') }, value)));
19
19
  }
@@ -1,7 +1,6 @@
1
1
  import React from 'react';
2
2
  import { block } from '../../../utils';
3
3
  import { getFormattedValue } from '../../../utils/chart/format';
4
- import { Row } from './Row';
5
4
  import { getBuiltInAggregatedValue, getBuiltInAggregationLabel } from './utils';
6
5
  const b = block('tooltip');
7
6
  export function RowWithAggregation(props) {
@@ -19,5 +18,7 @@ export function RowWithAggregation(props) {
19
18
  format: valueFormat || { type: 'number' },
20
19
  })
21
20
  : resultValue;
22
- return (React.createElement(Row, { className: b('content-row-totals'), label: resultLabel, style: style, value: formattedResultValue }));
21
+ return (React.createElement("div", { className: b('content-row', { totals: true }), style: style },
22
+ React.createElement("span", { className: b('content-row-totals-label') }, resultLabel),
23
+ formattedResultValue && (React.createElement("span", { className: b('content-row-totals-value') }, formattedResultValue))));
23
24
  }
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { Divider } from '@gravity-ui/uikit';
3
+ import parse from 'html-react-parser';
3
4
  import get from 'lodash/get';
4
5
  import isEqual from 'lodash/isEqual';
5
6
  import { usePrevious } from '../../../hooks';
@@ -35,7 +36,7 @@ export const DefaultTooltipContent = ({ hovered, pinned, rowRenderer, totals, va
35
36
  hovered,
36
37
  });
37
38
  if (typeof result === 'string') {
38
- return React.createElement("div", { key: id, dangerouslySetInnerHTML: { __html: result } });
39
+ return React.createElement(React.Fragment, { key: id }, parse(result));
39
40
  }
40
41
  return result;
41
42
  }
@@ -75,127 +76,136 @@ export const DefaultTooltipContent = ({ hovered, pinned, rowRenderer, totals, va
75
76
  setScrollBarWidth(0);
76
77
  }
77
78
  }, [pinned]);
79
+ const rowsContent = (React.createElement(React.Fragment, null, visibleHovered.map((seriesItem, i) => {
80
+ var _a;
81
+ const { data, series, closest } = seriesItem;
82
+ const id = `${get(series, 'id')}_${i}`;
83
+ const color = get(data, 'color') || get(series, 'color');
84
+ // TODO: improve active item display https://github.com/gravity-ui/charts/issues/208
85
+ const active = closest && hovered.length > 1;
86
+ const striped = (i + 1) % 2 === 0;
87
+ const rowValueFormat = get(series, 'tooltip.valueFormat', valueFormat);
88
+ switch (series.type) {
89
+ case 'scatter':
90
+ case 'line':
91
+ case 'area':
92
+ case 'bar-x': {
93
+ const format = rowValueFormat || getDefaultValueFormat({ axis: yAxis });
94
+ const formattedValue = getFormattedValue({
95
+ value: hoveredValues[i],
96
+ format,
97
+ });
98
+ return renderRow({
99
+ id,
100
+ active,
101
+ color,
102
+ name: series.name,
103
+ striped,
104
+ value: hoveredValues[i],
105
+ formattedValue,
106
+ series,
107
+ });
108
+ }
109
+ case 'waterfall': {
110
+ const isTotal = get(data, 'total', false);
111
+ const subTotalValue = (_a = seriesItem.subTotal) !== null && _a !== void 0 ? _a : 0;
112
+ const format = rowValueFormat || getDefaultValueFormat({ axis: yAxis });
113
+ const subTotal = getFormattedValue({
114
+ value: subTotalValue,
115
+ format,
116
+ });
117
+ const formattedValue = getFormattedValue({
118
+ value: hoveredValues[i],
119
+ format,
120
+ });
121
+ return (React.createElement(React.Fragment, { key: id },
122
+ !isTotal && (React.createElement(React.Fragment, null,
123
+ React.createElement("div", { className: b('series-name') }, getXRowData(data, xAxis)),
124
+ React.createElement(Row, { label: series.name, value: formattedValue }))),
125
+ React.createElement(Row, { label: isTotal ? 'Total' : 'Subtotal', value: subTotal })));
126
+ }
127
+ case 'bar-y': {
128
+ const format = rowValueFormat || getDefaultValueFormat({ axis: xAxis });
129
+ const formattedValue = getFormattedValue({
130
+ value: hoveredValues[i],
131
+ format,
132
+ });
133
+ return renderRow({
134
+ id,
135
+ active,
136
+ color,
137
+ name: series.name,
138
+ striped,
139
+ value: hoveredValues[i],
140
+ formattedValue,
141
+ });
142
+ }
143
+ case 'pie':
144
+ case 'heatmap':
145
+ case 'treemap':
146
+ case 'funnel': {
147
+ const seriesData = data;
148
+ const formattedValue = getFormattedValue({
149
+ value: hoveredValues[i],
150
+ format: rowValueFormat || { type: 'number' },
151
+ });
152
+ return renderRow({
153
+ id,
154
+ color,
155
+ name: [seriesData.name || seriesData.id].flat().join('\n'),
156
+ value: hoveredValues[i],
157
+ formattedValue,
158
+ });
159
+ }
160
+ case 'sankey': {
161
+ const { target, data: source } = seriesItem;
162
+ const formattedValue = getFormattedValue({
163
+ value: hoveredValues[i],
164
+ format: rowValueFormat || { type: 'number' },
165
+ });
166
+ return renderRow({
167
+ id,
168
+ color,
169
+ name: `${source.name} → ${target === null || target === void 0 ? void 0 : target.name}`,
170
+ value: hoveredValues[i],
171
+ formattedValue,
172
+ });
173
+ }
174
+ case 'radar': {
175
+ const radarSeries = series;
176
+ const formattedValue = getFormattedValue({
177
+ value: hoveredValues[i],
178
+ format: rowValueFormat || { type: 'number' },
179
+ });
180
+ return renderRow({
181
+ id,
182
+ color,
183
+ active,
184
+ name: radarSeries.name || radarSeries.id,
185
+ value: hoveredValues[i],
186
+ formattedValue,
187
+ });
188
+ }
189
+ default: {
190
+ return null;
191
+ }
192
+ }
193
+ })));
78
194
  return (React.createElement("div", { className: b('content'), "data-qa": qa },
79
195
  formattedHeadValue && (React.createElement("div", { className: b('series-name') },
80
196
  React.createElement("div", { className: b('series-name-text'), dangerouslySetInnerHTML: { __html: formattedHeadValue } }))),
81
197
  React.createElement("div", { className: b('content-rows', { pinned }), ref: contentRowsRef, style: pinned ? { maxHeight: maxContentRowsHeight } : undefined },
82
- visibleHovered.map((seriesItem, i) => {
83
- var _a;
84
- const { data, series, closest } = seriesItem;
85
- const id = `${get(series, 'id')}_${i}`;
86
- const color = get(data, 'color') || get(series, 'color');
87
- // TODO: improve action item display https://github.com/gravity-ui/charts/issues/208
88
- const active = closest && hovered.length > 1;
89
- const striped = (i + 1) % 2 === 0;
90
- const rowValueFormat = get(series, 'tooltip.valueFormat', valueFormat);
91
- switch (series.type) {
92
- case 'scatter':
93
- case 'line':
94
- case 'area':
95
- case 'bar-x': {
96
- const format = rowValueFormat || getDefaultValueFormat({ axis: yAxis });
97
- const formattedValue = getFormattedValue({
98
- value: hoveredValues[i],
99
- format,
100
- });
101
- return renderRow({
102
- id,
103
- active,
104
- color,
105
- name: series.name,
106
- striped,
107
- value: hoveredValues[i],
108
- formattedValue,
109
- series,
110
- });
111
- }
112
- case 'waterfall': {
113
- const isTotal = get(data, 'total', false);
114
- const subTotalValue = (_a = seriesItem.subTotal) !== null && _a !== void 0 ? _a : 0;
115
- const format = rowValueFormat || getDefaultValueFormat({ axis: yAxis });
116
- const subTotal = getFormattedValue({
117
- value: subTotalValue,
118
- format,
119
- });
120
- const formattedValue = getFormattedValue({
121
- value: hoveredValues[i],
122
- format,
123
- });
124
- return (React.createElement(React.Fragment, { key: id },
125
- !isTotal && (React.createElement(React.Fragment, null,
126
- React.createElement("div", { className: b('series-name') }, getXRowData(data, xAxis)),
127
- React.createElement(Row, { label: series.name, value: formattedValue }))),
128
- React.createElement(Row, { label: isTotal ? 'Total' : 'Subtotal', value: subTotal })));
129
- }
130
- case 'bar-y': {
131
- const format = rowValueFormat || getDefaultValueFormat({ axis: xAxis });
132
- const formattedValue = getFormattedValue({
133
- value: hoveredValues[i],
134
- format,
135
- });
136
- return renderRow({
137
- id,
138
- active,
139
- color,
140
- name: series.name,
141
- striped,
142
- value: hoveredValues[i],
143
- formattedValue,
144
- });
145
- }
146
- case 'pie':
147
- case 'heatmap':
148
- case 'treemap':
149
- case 'funnel': {
150
- const seriesData = data;
151
- const formattedValue = getFormattedValue({
152
- value: hoveredValues[i],
153
- format: rowValueFormat || { type: 'number' },
154
- });
155
- return renderRow({
156
- id,
157
- color,
158
- name: [seriesData.name || seriesData.id].flat().join('\n'),
159
- value: hoveredValues[i],
160
- formattedValue,
161
- });
162
- }
163
- case 'sankey': {
164
- const { target, data: source } = seriesItem;
165
- const formattedValue = getFormattedValue({
166
- value: hoveredValues[i],
167
- format: rowValueFormat || { type: 'number' },
168
- });
169
- return renderRow({
170
- id,
171
- color,
172
- name: `${source.name} → ${target === null || target === void 0 ? void 0 : target.name}`,
173
- value: hoveredValues[i],
174
- formattedValue,
175
- });
176
- }
177
- case 'radar': {
178
- const radarSeries = series;
179
- const formattedValue = getFormattedValue({
180
- value: hoveredValues[i],
181
- format: rowValueFormat || { type: 'number' },
182
- });
183
- return renderRow({
184
- id,
185
- color,
186
- active,
187
- name: radarSeries.name || radarSeries.id,
188
- value: hoveredValues[i],
189
- formattedValue,
190
- });
191
- }
192
- default: {
193
- return null;
194
- }
195
- }
196
- }),
197
- Boolean(restHoveredValues.length) && (React.createElement(Row, { label: i18n('tooltip', 'label_more', { count: restHoveredValues.length }), striped: (visibleHovered.length + 1) % 2 === 0 }))),
198
+ React.createElement("table", { className: b('content-rows-table') },
199
+ React.createElement("tbody", null, rowsContent))),
200
+ Boolean(restHoveredValues.length) && (React.createElement("div", { className: b('content-row', {
201
+ striped: (visibleHovered.length + 1) % 2 === 0,
202
+ }) }, i18n('tooltip', 'label_more', { count: restHoveredValues.length }))),
198
203
  (totals === null || totals === void 0 ? void 0 : totals.enabled) && hovered.length > 1 && (React.createElement(React.Fragment, null,
199
204
  React.createElement(Divider, { className: b('content-row-totals-divider') }),
200
- React.createElement(RowWithAggregation, { aggregation: getPreparedAggregation({ hovered, totals, xAxis, yAxis }), label: totals.label, style: { marginRight: scrollBarWidth }, values: hoveredValues, valueFormat: (_a = totals.valueFormat) !== null && _a !== void 0 ? _a : valueFormat })))));
205
+ React.createElement(RowWithAggregation, { aggregation: getPreparedAggregation({
206
+ hovered,
207
+ totals,
208
+ xAxis,
209
+ yAxis,
210
+ }), label: totals.label, style: { marginRight: scrollBarWidth }, values: hoveredValues, valueFormat: (_a = totals.valueFormat) !== null && _a !== void 0 ? _a : valueFormat })))));
201
211
  };
@@ -3,8 +3,12 @@
3
3
  background-color: var(--g-color-infographics-tooltip-bg);
4
4
  box-shadow: 0 2px 12px var(--g-color-sfx-shadow);
5
5
  }
6
+ tr.gcharts-tooltip__content-row {
7
+ display: table-row;
8
+ padding: 0;
9
+ }
10
+
6
11
  .gcharts-tooltip__popup-content {
7
- max-width: 450px;
8
12
  text-wrap: nowrap;
9
13
  border-radius: 4px;
10
14
  }
@@ -20,7 +24,13 @@
20
24
  .gcharts-tooltip__content-rows_pinned {
21
25
  overflow: auto;
22
26
  }
27
+ .gcharts-tooltip__content-rows-table {
28
+ width: 100%;
29
+ padding: 0;
30
+ border-collapse: collapse;
31
+ }
23
32
  .gcharts-tooltip__series-name {
33
+ max-width: 450px;
24
34
  padding: 2px 14px 6px;
25
35
  font-size: 13px;
26
36
  font-weight: 600;
@@ -45,8 +55,23 @@
45
55
  font-weight: 600;
46
56
  background-color: var(--g-color-base-info-medium);
47
57
  }
48
- .gcharts-tooltip__content-row-label {
58
+ .gcharts-tooltip__content-row_totals {
59
+ color: var(--g-color-text-complementary);
60
+ }
61
+ .gcharts-tooltip__content-row-totals-label {
49
62
  overflow: hidden;
63
+ max-width: 400px;
64
+ white-space: nowrap;
65
+ text-overflow: ellipsis;
66
+ }
67
+ .gcharts-tooltip__content-row-totals-value {
68
+ flex-shrink: 0;
69
+ margin-inline-start: auto;
70
+ }
71
+ .gcharts-tooltip__content-row-label-cell {
72
+ overflow: hidden;
73
+ max-width: 400px;
74
+ padding: 2px 4px;
50
75
  white-space: nowrap;
51
76
  text-overflow: ellipsis;
52
77
  }
@@ -58,12 +83,13 @@
58
83
  border-radius: 2px;
59
84
  background-color: #dddddd;
60
85
  }
61
- .gcharts-tooltip__content-row-value {
62
- flex-shrink: 0;
63
- margin-inline-start: auto;
86
+ .gcharts-tooltip__content-row-color-cell {
87
+ width: 16px;
88
+ padding: 2px 4px 2px 14px;
64
89
  }
65
- .gcharts-tooltip__content-row-totals {
66
- color: var(--g-color-text-complementary);
90
+ .gcharts-tooltip__content-row-value-cell {
91
+ padding: 2px 14px 2px 4px;
92
+ text-align: end;
67
93
  }
68
94
  .gcharts-tooltip__content-row-totals-divider {
69
95
  margin-block: 5px 5px;
@@ -1,8 +1,11 @@
1
1
  import React from 'react';
2
2
  import type { ChartData } from '../types';
3
3
  export * from './Tooltip/ChartTooltipContent';
4
+ export interface ChartReflowOptions {
5
+ immediate?: boolean;
6
+ }
4
7
  export interface ChartRef {
5
- reflow: () => void;
8
+ reflow: (options?: ChartReflowOptions) => void;
6
9
  }
7
10
  export interface ChartDimentions {
8
11
  height: number;
@@ -30,10 +30,15 @@ export const Chart = React.forwardRef(function Chart(props, forwardedRef) {
30
30
  return debounced.current;
31
31
  }, [handleResize]);
32
32
  React.useImperativeHandle(forwardedRef, () => ({
33
- reflow() {
34
- debuncedHandleResize();
33
+ reflow(options) {
34
+ if (options === null || options === void 0 ? void 0 : options.immediate) {
35
+ handleResize();
36
+ }
37
+ else {
38
+ debuncedHandleResize();
39
+ }
35
40
  },
36
- }), [debuncedHandleResize]);
41
+ }), [debuncedHandleResize, handleResize]);
37
42
  React.useEffect(() => {
38
43
  // dimensions initialize
39
44
  handleResize();
@@ -162,6 +162,7 @@ export const prepareBarXData = async (args) => {
162
162
  : yAxisTop + base + negativeStackHeight,
163
163
  width: rectWidth,
164
164
  height: shapeHeight,
165
+ _height: height,
165
166
  opacity: get(yValue.data, 'opacity', null),
166
167
  data: yValue.data,
167
168
  series: yValue.series,
@@ -178,9 +179,9 @@ export const prepareBarXData = async (args) => {
178
179
  }
179
180
  if (series.some((s) => s.stacking === 'percent')) {
180
181
  let acc = 0;
181
- const ratio = plotHeight / (positiveStackHeight - (stackItems.length - 1) * stackGap);
182
+ const ratio = plotHeight / positiveStackHeight;
182
183
  stackItems.forEach((item) => {
183
- item.height = item.height * ratio;
184
+ item.height = item._height * ratio;
184
185
  item.y = plotHeight - item.height - acc;
185
186
  acc += item.height + 1;
186
187
  });
@@ -10,4 +10,9 @@ export type PreparedBarXData = Omit<TooltipDataChunkBarX, 'series'> & {
10
10
  label?: LabelData;
11
11
  htmlElements: HtmlItem[];
12
12
  isLastStackItem: boolean;
13
+ /**
14
+ * the utility field for storing the original height (for recalculations, etc.)
15
+ * should not be used for displaying
16
+ */
17
+ _height: number;
13
18
  };
@@ -113,6 +113,10 @@ export type ChartTooltipRowRendererArgs = {
113
113
  value: string | number | null | undefined;
114
114
  formattedValue?: string;
115
115
  hovered?: TooltipDataChunk<unknown>[];
116
+ /**
117
+ * CSS class name pre-built with active/striped modifiers.
118
+ * Apply it to the root `<tr>` element of the returned row: `<tr className={className}>`.
119
+ */
116
120
  className?: string;
117
121
  };
118
122
  export type ChartTooltipSortComparator<T = MeaningfulAny> = (a: TooltipDataChunk<T>, b: TooltipDataChunk<T>) => number;
@@ -123,7 +127,26 @@ export interface ChartTooltip<T = MeaningfulAny> {
123
127
  /**
124
128
  * Defines the way a single data/series is displayed (corresponding to a separate selected point/ruler/shape on the chart).
125
129
  * It is useful in cases where you need to display additional information, but keep the general format of the tooltip.
126
- * If a string is returned, it will be interpreted as raw HTML and inserted without escaping.
130
+ *
131
+ * The returned React element must be a `<tr>` so that it fits into the table layout used by the tooltip.
132
+ * Apply the `className` arg to the root `<tr>` to get the correct active/striped styles.
133
+ *
134
+ * If a string is returned, it will be parsed as HTML and rendered as-is — the string must be a complete
135
+ * `<tr>...</tr>` element.
136
+ * @example React element
137
+ * ```tsx
138
+ * rowRenderer: ({id, name, value, className}) => (
139
+ * <tr key={id} className={className}>
140
+ * <td>{name}</td>
141
+ * <td>{value}</td>
142
+ * </tr>
143
+ * )
144
+ * ```
145
+ * @example Raw HTML string
146
+ * ```ts
147
+ * rowRenderer: ({name, value, className}) =>
148
+ * `<tr class="${className}"><td>${name}</td><td>${value}</td></tr>`
149
+ * ```
127
150
  */
128
151
  rowRenderer?: ((args: ChartTooltipRowRendererArgs) => React.ReactElement | string) | null;
129
152
  pin?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gravity-ui/charts",
3
- "version": "1.40.0",
3
+ "version": "1.41.0",
4
4
  "description": "A flexible JavaScript library for data visualization and chart rendering using React",
5
5
  "license": "MIT",
6
6
  "main": "dist/cjs/index.js",
@@ -73,6 +73,7 @@
73
73
  "d3": "^7.9.0",
74
74
  "d3-sankey": "^0.12.3",
75
75
  "d3-selection": "^3.0.0",
76
+ "html-react-parser": "^5.2.17",
76
77
  "lodash": "^4.17.21",
77
78
  "tslib": "^2.6.2"
78
79
  },