@gravity-ui/charts 1.4.0 → 1.5.1

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.
@@ -6,7 +6,7 @@ import { prepareLegendSymbol } from './utils';
6
6
  export function prepareTreemap(args) {
7
7
  const { colorScale, legend, series } = args;
8
8
  return series.map((s) => {
9
- var _a, _b;
9
+ var _a, _b, _c;
10
10
  const id = getUniqId();
11
11
  const name = s.name || '';
12
12
  const color = s.color || colorScale(name);
@@ -30,9 +30,10 @@ export function prepareTreemap(args) {
30
30
  enabled: get(s, 'legend.enabled', legend.enabled),
31
31
  symbol: prepareLegendSymbol(s),
32
32
  },
33
- levels: s.levels,
33
+ levels: (_c = s.levels) !== null && _c !== void 0 ? _c : [],
34
34
  layoutAlgorithm: get(s, 'layoutAlgorithm', LayoutAlgorithm.Binary),
35
35
  cursor: get(s, 'cursor', null),
36
+ sorting: Object.assign({ enabled: false, direction: 'desc' }, s.sorting),
36
37
  };
37
38
  return preparedSeries;
38
39
  });
@@ -242,7 +242,8 @@ export type PreparedTreemapSeries = {
242
242
  format?: ValueFormat;
243
243
  };
244
244
  layoutAlgorithm: `${LayoutAlgorithm}`;
245
- } & BasePreparedSeries & Omit<TreemapSeries, keyof BasePreparedSeries>;
245
+ sorting: Required<TreemapSeries['sorting']>;
246
+ } & BasePreparedSeries & Required<Omit<TreemapSeries, keyof BasePreparedSeries>>;
246
247
  export type PreparedWaterfallSeriesData = WaterfallSeriesData & {
247
248
  index: number;
248
249
  };
@@ -1,7 +1,7 @@
1
1
  import { arc, group, line as lineGenerator } from 'd3';
2
2
  import { calculateNumericProperty, getLabelsSize, getLeftPosition, isLabelsOverlapping, } from '../../../utils';
3
3
  import { getFormattedValue } from '../../../utils/chart/format';
4
- import { getCurveFactory, pieGenerator } from './utils';
4
+ import { getCurveFactory, getInscribedAngle, pieGenerator } from './utils';
5
5
  const FULL_CIRCLE = Math.PI * 2;
6
6
  const getCenter = (boundsWidth, boundsHeight, center) => {
7
7
  var _a, _b;
@@ -23,7 +23,6 @@ export function preparePieData(args) {
23
23
  const maxRadius = Math.min(boundsWidth, boundsHeight) / 2 - haloSize;
24
24
  const minRadius = maxRadius * 0.3;
25
25
  const groupedPieSeries = group(preparedSeries, (pieSeries) => pieSeries.stackId);
26
- let maxMissingWidth = 0;
27
26
  const prepareItem = (stackId, items) => {
28
27
  var _a;
29
28
  const series = items[0];
@@ -77,7 +76,7 @@ export function preparePieData(args) {
77
76
  return data;
78
77
  };
79
78
  const prepareLabels = (prepareLabelsArgs) => {
80
- const { data, series } = prepareLabelsArgs;
79
+ const { data, series, allowOverlow = true } = prepareLabelsArgs;
81
80
  const { dataLabels } = series[0];
82
81
  const labels = [];
83
82
  const htmlLabels = [];
@@ -104,6 +103,7 @@ export function preparePieData(args) {
104
103
  const labelArcGenerator = arc()
105
104
  .innerRadius((d) => d.data.radius + distance + connectorPadding)
106
105
  .outerRadius((d) => d.data.radius + distance + connectorPadding);
106
+ let shouldStopLabelPlacement = false;
107
107
  series.forEach((d, index) => {
108
108
  const prevLabel = labels[labels.length - 1];
109
109
  const text = getFormattedValue(Object.assign({ value: d.data.label || d.data.value }, d.dataLabels));
@@ -120,7 +120,6 @@ export function preparePieData(args) {
120
120
  else {
121
121
  y = y < 0 ? y - labelHeight : y;
122
122
  }
123
- x = Math.max(-boundsWidth / 2, x);
124
123
  return [x, y];
125
124
  };
126
125
  const getConnectorPoints = (angle) => {
@@ -148,11 +147,25 @@ export function preparePieData(args) {
148
147
  segment: relatedSegment.data,
149
148
  angle: midAngle,
150
149
  };
150
+ if (!allowOverlow) {
151
+ const labelLeftPosition = getLeftPosition(label);
152
+ const newMaxWidth = labelLeftPosition > 0
153
+ ? Math.min(boundsWidth / 2 - labelLeftPosition, labelWidth)
154
+ : Math.min(labelWidth - (-labelLeftPosition - boundsWidth / 2), labelWidth);
155
+ if (newMaxWidth !== label.maxWidth) {
156
+ label.maxWidth = Math.max(0, newMaxWidth);
157
+ }
158
+ }
151
159
  let overlap = false;
152
160
  if (prevLabel) {
153
161
  overlap = isLabelsOverlapping(prevLabel, label, dataLabels.padding);
162
+ const startAngle = relatedSegment.startAngle +
163
+ (relatedSegment.endAngle - relatedSegment.startAngle) / 2;
154
164
  if (overlap) {
155
- let shouldAdjustAngle = true;
165
+ let shouldAdjustAngle = !shouldStopLabelPlacement;
166
+ const connectorPoints = getConnectorPoints(startAngle);
167
+ const pointA = connectorPoints[0];
168
+ const pointB = connectorPoints[connectorPoints.length - 1];
156
169
  const step = Math.PI / 180;
157
170
  while (shouldAdjustAngle) {
158
171
  const newAngle = label.angle + step;
@@ -164,6 +177,11 @@ export function preparePieData(args) {
164
177
  const [newX, newY] = getLabelPosition(newAngle);
165
178
  label.x = newX;
166
179
  label.y = newY;
180
+ const inscribedAngle = getInscribedAngle(pointA, pointB, [newX, newY]);
181
+ if (inscribedAngle > 90) {
182
+ shouldAdjustAngle = false;
183
+ shouldStopLabelPlacement = true;
184
+ }
167
185
  if (!isLabelsOverlapping(prevLabel, label, dataLabels.padding)) {
168
186
  shouldAdjustAngle = false;
169
187
  overlap = false;
@@ -172,21 +190,8 @@ export function preparePieData(args) {
172
190
  }
173
191
  }
174
192
  }
175
- if (dataLabels.allowOverlap || !overlap) {
176
- const left = getLeftPosition(label);
177
- if (Math.abs(left) > boundsWidth / 2) {
178
- const overflow = Math.abs(left) - boundsWidth / 2;
179
- label.maxWidth = label.size.width - overflow;
180
- maxMissingWidth = Math.max(maxMissingWidth, overflow);
181
- }
182
- else {
183
- const right = left + label.size.width;
184
- if (right > boundsWidth / 2) {
185
- const overflow = right - boundsWidth / 2;
186
- label.maxWidth = label.size.width - overflow;
187
- maxMissingWidth = Math.max(maxMissingWidth, overflow);
188
- }
189
- }
193
+ const isLabelOverlapped = !dataLabels.allowOverlap && overlap;
194
+ if (!isLabelOverlapped && label.maxWidth > 0 && !shouldStopLabelPlacement) {
190
195
  if (shouldUseHtml) {
191
196
  htmlLabels.push({
192
197
  x: data.center[0] + label.x,
@@ -200,7 +205,7 @@ export function preparePieData(args) {
200
205
  labels.push(label);
201
206
  }
202
207
  const connector = {
203
- path: line(getConnectorPoints(midAngle)),
208
+ path: line(getConnectorPoints(label.angle)),
204
209
  color: relatedSegment.data.color,
205
210
  };
206
211
  connectors.push(connector);
@@ -218,43 +223,71 @@ export function preparePieData(args) {
218
223
  data,
219
224
  series: items,
220
225
  });
226
+ let maxLeftRightFreeSpace = Infinity;
227
+ let labelsOverflow = 0;
228
+ preparedLabels.labels.forEach((label) => {
229
+ const left = getLeftPosition(label);
230
+ let freeSpace = 0;
231
+ if (left < 0) {
232
+ freeSpace = boundsWidth / 2 - Math.abs(left);
233
+ }
234
+ else {
235
+ freeSpace = boundsWidth / 2 - (left + label.size.width);
236
+ }
237
+ maxLeftRightFreeSpace = Math.max(0, Math.min(maxLeftRightFreeSpace, freeSpace));
238
+ labelsOverflow = freeSpace < 0 ? Math.max(labelsOverflow, -freeSpace) : labelsOverflow;
239
+ });
221
240
  const segmentMaxRadius = Math.max(...data.segments.map((s) => s.data.radius));
222
- const topAdjustment = Math.min(data.center[1] - segmentMaxRadius, ...preparedLabels.labels.map((l) => l.y + data.center[1]), ...preparedLabels.htmlLabels.map((l) => l.y));
223
- const bottom = Math.max(data.center[1] + segmentMaxRadius, ...preparedLabels.labels.map((l) => l.y + data.center[1] + l.size.height), ...preparedLabels.htmlLabels.map((l) => l.y + l.size.height));
224
- if (topAdjustment > 0) {
241
+ if (labelsOverflow) {
225
242
  data.segments.forEach((s) => {
226
- const nextPossibleRadius = s.data.radius + topAdjustment / 2;
227
- s.data.radius = Math.min(nextPossibleRadius, maxRadius);
243
+ const neeSegmentRadius = Math.max(minRadius, s.data.radius - labelsOverflow);
244
+ s.data.radius = neeSegmentRadius;
228
245
  });
229
- data.center[1] -= topAdjustment / 2;
230
246
  }
231
- const bottomAdjustment = Math.floor(boundsHeight - bottom);
232
- if (bottomAdjustment > 0) {
233
- data.segments.forEach((s) => {
234
- const nextPossibleRadius = s.data.radius + bottomAdjustment / 2;
235
- s.data.radius = Math.min(nextPossibleRadius, maxRadius);
236
- });
237
- data.center[1] += bottomAdjustment / 2;
247
+ else {
248
+ let topFreeSpace = data.center[1] - segmentMaxRadius - haloSize;
249
+ if (preparedLabels.labels.length) {
250
+ const topSvgLabel = Math.max(0, ...preparedLabels.labels.map((l) => -l.y));
251
+ topFreeSpace = Math.min(topFreeSpace, data.center[1] - topSvgLabel);
252
+ }
253
+ if (preparedLabels.htmlLabels.length) {
254
+ const topHtmlLabel = Math.max(0, ...preparedLabels.htmlLabels.map((l) => l.y));
255
+ topFreeSpace = Math.min(topFreeSpace, topHtmlLabel);
256
+ }
257
+ let bottomFreeSpace = data.center[1] - segmentMaxRadius - haloSize;
258
+ if (preparedLabels.labels.length) {
259
+ const bottomSvgLabel = Math.max(0, ...preparedLabels.labels.map((l) => l.y + l.size.height));
260
+ bottomFreeSpace = Math.min(bottomFreeSpace, data.center[1] - bottomSvgLabel);
261
+ }
262
+ if (preparedLabels.htmlLabels.length) {
263
+ const bottomHtmlLabel = Math.max(0, ...preparedLabels.htmlLabels.map((l) => l.y + l.size.height));
264
+ bottomFreeSpace = Math.min(bottomFreeSpace, data.center[1] * 2 - bottomHtmlLabel);
265
+ }
266
+ const topAdjustment = Math.max(0, Math.min(topFreeSpace, maxLeftRightFreeSpace));
267
+ const bottomAdjustment = Math.max(0, Math.min(bottomFreeSpace, maxLeftRightFreeSpace));
268
+ if (topAdjustment && topAdjustment >= bottomAdjustment) {
269
+ data.segments.forEach((s) => {
270
+ const nextPossibleRadius = s.data.radius + (topAdjustment + bottomAdjustment) / 2;
271
+ s.data.radius = Math.min(nextPossibleRadius, maxRadius);
272
+ });
273
+ data.center[1] -= (topAdjustment - bottomAdjustment) / 2;
274
+ }
275
+ else if (bottomAdjustment) {
276
+ data.segments.forEach((s) => {
277
+ const nextPossibleRadius = s.data.radius + (topAdjustment + bottomAdjustment) / 2;
278
+ s.data.radius = Math.min(nextPossibleRadius, maxRadius);
279
+ });
280
+ data.center[1] += (bottomAdjustment - topAdjustment) / 2;
281
+ }
238
282
  }
239
283
  const { labels, htmlLabels, connectors } = prepareLabels({
240
284
  data,
241
285
  series: items,
286
+ allowOverlow: false,
242
287
  });
243
288
  data.labels = labels;
244
289
  data.htmlLabels = htmlLabels;
245
290
  data.connectors = connectors;
246
- if (maxMissingWidth > 0) {
247
- const { dataLabels } = items[0];
248
- if (dataLabels.enabled) {
249
- data.segments.forEach((s) => {
250
- s.data.radius = Math.max(minRadius, s.data.radius - maxMissingWidth);
251
- });
252
- const finalLabels = prepareLabels({ data, series: items });
253
- data.labels = finalLabels.labels;
254
- data.htmlLabels = finalLabels.htmlLabels;
255
- data.connectors = finalLabels.connectors;
256
- }
257
- }
258
291
  return data;
259
292
  });
260
293
  }
@@ -1,4 +1,14 @@
1
1
  import type { CurveFactory } from 'd3';
2
+ import type { PointPosition } from '../../../types';
2
3
  import type { PreparedPieData, SegmentData } from './types';
3
4
  export declare const pieGenerator: import("d3-shape").Pie<any, SegmentData>;
4
5
  export declare function getCurveFactory(data: PreparedPieData): CurveFactory | undefined;
6
+ /**
7
+ * Inscribed angle at vertex A (opposite side/chord BC): the angle between rays AB and AC.
8
+ *
9
+ * The order of B and C does not affect the result.
10
+ *
11
+ * @see: https://en.wikipedia.org/wiki/Inscribed_angle
12
+ * @returns The angle in degrees, in the range [0, 180].
13
+ */
14
+ export declare function getInscribedAngle(a: PointPosition, b: PointPosition, c: PointPosition): number;
@@ -13,3 +13,21 @@ export function getCurveFactory(data) {
13
13
  }
14
14
  return undefined;
15
15
  }
16
+ /**
17
+ * Inscribed angle at vertex A (opposite side/chord BC): the angle between rays AB and AC.
18
+ *
19
+ * The order of B and C does not affect the result.
20
+ *
21
+ * @see: https://en.wikipedia.org/wiki/Inscribed_angle
22
+ * @returns The angle in degrees, in the range [0, 180].
23
+ */
24
+ export function getInscribedAngle(a, b, c) {
25
+ const ux = b[0] - a[0];
26
+ const uy = b[1] - a[1];
27
+ const vx = c[0] - a[0];
28
+ const vy = c[1] - a[1];
29
+ const dot = ux * vx + uy * vy;
30
+ const cross = ux * vy - uy * vx;
31
+ const radians = Math.atan2(Math.abs(cross), dot);
32
+ return (radians * 180) / Math.PI;
33
+ }
@@ -1,16 +1,16 @@
1
- import { stratify, treemap, treemapBinary, treemapDice, treemapSlice, treemapSliceDice, treemapSquarify, } from 'd3';
1
+ import { ascending, descending, sort, stratify, treemap, treemapBinary, treemapDice, treemapSlice, treemapSliceDice, treemapSquarify, } from 'd3';
2
2
  import { LayoutAlgorithm } from '../../../constants';
3
3
  import { getLabelsSize } from '../../../utils';
4
4
  import { getFormattedValue } from '../../../utils/chart/format';
5
5
  const DEFAULT_PADDING = 1;
6
6
  function getLabels(args) {
7
- const { data, options: { html, padding, align }, } = args;
7
+ const { data, options: { html, padding, align, style }, } = args;
8
8
  return data.reduce((acc, d) => {
9
9
  const texts = Array.isArray(d.data.name) ? d.data.name : [d.data.name];
10
10
  texts.forEach((text, index) => {
11
11
  var _a;
12
12
  const label = getFormattedValue(Object.assign({ value: text }, args.options));
13
- const { maxHeight: lineHeight, maxWidth: labelMaxWidth } = (_a = getLabelsSize({ labels: [label], html })) !== null && _a !== void 0 ? _a : {};
13
+ const { maxHeight: lineHeight, maxWidth: labelMaxWidth } = (_a = getLabelsSize({ labels: [label], style, html })) !== null && _a !== void 0 ? _a : {};
14
14
  const left = d.x0 + padding;
15
15
  const right = d.x1 - padding;
16
16
  const spaceWidth = Math.max(0, right - left);
@@ -35,6 +35,10 @@ function getLabels(args) {
35
35
  break;
36
36
  }
37
37
  }
38
+ const bottom = y + lineHeight;
39
+ if (bottom > d.y1) {
40
+ return;
41
+ }
38
42
  const item = html
39
43
  ? {
40
44
  content: label,
@@ -57,7 +61,25 @@ function getLabels(args) {
57
61
  export function prepareTreemapData(args) {
58
62
  var _a;
59
63
  const { series, width, height } = args;
60
- const dataWithRootNode = getSeriesDataWithRootNode(series);
64
+ const parentNodeValues = {};
65
+ let dataWithRootNode = series.data.reduce((acc, d) => {
66
+ var _a, _b;
67
+ const dataChunk = Object.assign({}, d);
68
+ if (!dataChunk.parentId) {
69
+ dataChunk.parentId = series.id;
70
+ }
71
+ if (dataChunk.parentId) {
72
+ parentNodeValues[dataChunk.parentId] =
73
+ ((_a = parentNodeValues[dataChunk.parentId]) !== null && _a !== void 0 ? _a : 0) + ((_b = dataChunk.value) !== null && _b !== void 0 ? _b : 0);
74
+ }
75
+ acc.push(dataChunk);
76
+ return acc;
77
+ }, [{ name: series.name, id: series.id }]);
78
+ if (series.sorting.enabled) {
79
+ const getSortingValue = (d) => { var _a, _b; return (_a = d.value) !== null && _a !== void 0 ? _a : parentNodeValues[(_b = d.id) !== null && _b !== void 0 ? _b : '']; };
80
+ const comparator = series.sorting.direction === 'desc' ? descending : ascending;
81
+ dataWithRootNode = sort(dataWithRootNode, (a, b) => comparator(getSortingValue(a), getSortingValue(b)));
82
+ }
61
83
  const hierarchy = stratify()
62
84
  .id((d) => {
63
85
  if (d.id) {
@@ -102,7 +124,7 @@ export function prepareTreemapData(args) {
102
124
  const { html, style: dataLabelsStyle } = series.dataLabels;
103
125
  const labels = getLabels({ data: leaves, options: series.dataLabels });
104
126
  if (html) {
105
- const htmlItems = labels.map((l) => (Object.assign({ style: dataLabelsStyle }, l)));
127
+ const htmlItems = labels.map((l) => (Object.assign({ style: Object.assign(Object.assign({}, dataLabelsStyle), { maxWidth: l.size.width, maxHeight: l.size.height, overflow: 'hidden' }) }, l)));
106
128
  htmlElements.push(...htmlItems);
107
129
  }
108
130
  else {
@@ -111,13 +133,3 @@ export function prepareTreemapData(args) {
111
133
  }
112
134
  return { labelData, leaves, series, htmlElements };
113
135
  }
114
- function getSeriesDataWithRootNode(series) {
115
- return series.data.reduce((acc, d) => {
116
- const dataChunk = Object.assign({}, d);
117
- if (!dataChunk.parentId) {
118
- dataChunk.parentId = series.id;
119
- }
120
- acc.push(dataChunk);
121
- return acc;
122
- }, [{ name: series.name, id: series.id }]);
123
- }
@@ -43,4 +43,15 @@ export interface TreemapSeries<T = MeaningfulAny> extends BaseSeries {
43
43
  /** Horizontal alignment of the data label inside the tile. */
44
44
  align?: 'left' | 'center' | 'right';
45
45
  };
46
+ /** Data sorting settings (affects the order in which blocks are displayed inside the chart).
47
+ * If the option is not specified, the data is displayed in the order defined by the user. */
48
+ sorting?: {
49
+ /** Enable or disable sorting. */
50
+ enabled?: boolean;
51
+ /** The sorting direction.
52
+ *
53
+ * @default: 'desc'
54
+ */
55
+ direction?: 'asc' | 'desc';
56
+ };
46
57
  }
@@ -64,7 +64,7 @@ function renderLabels(selection, { labels, style = {}, attrs = {}, }) {
64
64
  return text;
65
65
  }
66
66
  export function getLabelsSize({ labels, style, rotation, html, }) {
67
- var _a, _b, _c;
67
+ var _a, _b, _c, _d, _e;
68
68
  if (!labels.filter(Boolean).length) {
69
69
  return { maxHeight: 0, maxWidth: 0 };
70
70
  }
@@ -74,7 +74,12 @@ export function getLabelsSize({ labels, style, rotation, html, }) {
74
74
  const result = { maxHeight: 0, maxWidth: 0 };
75
75
  let labelWrapper;
76
76
  if (html) {
77
- labelWrapper = container.append('div').style('position', 'absolute').node();
77
+ labelWrapper = container
78
+ .append('div')
79
+ .style('position', 'absolute')
80
+ .style('font-size', (_a = style === null || style === void 0 ? void 0 : style.fontSize) !== null && _a !== void 0 ? _a : '')
81
+ .style('font-weight', (_b = style === null || style === void 0 ? void 0 : style.fontWeight) !== null && _b !== void 0 ? _b : '')
82
+ .node();
78
83
  const { height, width } = labels.reduce((acc, l) => {
79
84
  var _a, _b;
80
85
  if (labelWrapper) {
@@ -97,9 +102,9 @@ export function getLabelsSize({ labels, style, rotation, html, }) {
97
102
  .attr('text-anchor', rotation > 0 ? 'start' : 'end')
98
103
  .style('transform', `rotate(${rotation}deg)`);
99
104
  }
100
- const rect = (_a = svg.select('g').node()) === null || _a === void 0 ? void 0 : _a.getBoundingClientRect();
101
- result.maxWidth = (_b = rect === null || rect === void 0 ? void 0 : rect.width) !== null && _b !== void 0 ? _b : 0;
102
- result.maxHeight = (_c = rect === null || rect === void 0 ? void 0 : rect.height) !== null && _c !== void 0 ? _c : 0;
105
+ const rect = (_c = svg.select('g').node()) === null || _c === void 0 ? void 0 : _c.getBoundingClientRect();
106
+ result.maxWidth = (_d = rect === null || rect === void 0 ? void 0 : rect.width) !== null && _d !== void 0 ? _d : 0;
107
+ result.maxHeight = (_e = rect === null || rect === void 0 ? void 0 : rect.height) !== null && _e !== void 0 ? _e : 0;
103
108
  }
104
109
  container.remove();
105
110
  return result;
@@ -6,7 +6,7 @@ import { prepareLegendSymbol } from './utils';
6
6
  export function prepareTreemap(args) {
7
7
  const { colorScale, legend, series } = args;
8
8
  return series.map((s) => {
9
- var _a, _b;
9
+ var _a, _b, _c;
10
10
  const id = getUniqId();
11
11
  const name = s.name || '';
12
12
  const color = s.color || colorScale(name);
@@ -30,9 +30,10 @@ export function prepareTreemap(args) {
30
30
  enabled: get(s, 'legend.enabled', legend.enabled),
31
31
  symbol: prepareLegendSymbol(s),
32
32
  },
33
- levels: s.levels,
33
+ levels: (_c = s.levels) !== null && _c !== void 0 ? _c : [],
34
34
  layoutAlgorithm: get(s, 'layoutAlgorithm', LayoutAlgorithm.Binary),
35
35
  cursor: get(s, 'cursor', null),
36
+ sorting: Object.assign({ enabled: false, direction: 'desc' }, s.sorting),
36
37
  };
37
38
  return preparedSeries;
38
39
  });
@@ -242,7 +242,8 @@ export type PreparedTreemapSeries = {
242
242
  format?: ValueFormat;
243
243
  };
244
244
  layoutAlgorithm: `${LayoutAlgorithm}`;
245
- } & BasePreparedSeries & Omit<TreemapSeries, keyof BasePreparedSeries>;
245
+ sorting: Required<TreemapSeries['sorting']>;
246
+ } & BasePreparedSeries & Required<Omit<TreemapSeries, keyof BasePreparedSeries>>;
246
247
  export type PreparedWaterfallSeriesData = WaterfallSeriesData & {
247
248
  index: number;
248
249
  };
@@ -1,7 +1,7 @@
1
1
  import { arc, group, line as lineGenerator } from 'd3';
2
2
  import { calculateNumericProperty, getLabelsSize, getLeftPosition, isLabelsOverlapping, } from '../../../utils';
3
3
  import { getFormattedValue } from '../../../utils/chart/format';
4
- import { getCurveFactory, pieGenerator } from './utils';
4
+ import { getCurveFactory, getInscribedAngle, pieGenerator } from './utils';
5
5
  const FULL_CIRCLE = Math.PI * 2;
6
6
  const getCenter = (boundsWidth, boundsHeight, center) => {
7
7
  var _a, _b;
@@ -23,7 +23,6 @@ export function preparePieData(args) {
23
23
  const maxRadius = Math.min(boundsWidth, boundsHeight) / 2 - haloSize;
24
24
  const minRadius = maxRadius * 0.3;
25
25
  const groupedPieSeries = group(preparedSeries, (pieSeries) => pieSeries.stackId);
26
- let maxMissingWidth = 0;
27
26
  const prepareItem = (stackId, items) => {
28
27
  var _a;
29
28
  const series = items[0];
@@ -77,7 +76,7 @@ export function preparePieData(args) {
77
76
  return data;
78
77
  };
79
78
  const prepareLabels = (prepareLabelsArgs) => {
80
- const { data, series } = prepareLabelsArgs;
79
+ const { data, series, allowOverlow = true } = prepareLabelsArgs;
81
80
  const { dataLabels } = series[0];
82
81
  const labels = [];
83
82
  const htmlLabels = [];
@@ -104,6 +103,7 @@ export function preparePieData(args) {
104
103
  const labelArcGenerator = arc()
105
104
  .innerRadius((d) => d.data.radius + distance + connectorPadding)
106
105
  .outerRadius((d) => d.data.radius + distance + connectorPadding);
106
+ let shouldStopLabelPlacement = false;
107
107
  series.forEach((d, index) => {
108
108
  const prevLabel = labels[labels.length - 1];
109
109
  const text = getFormattedValue(Object.assign({ value: d.data.label || d.data.value }, d.dataLabels));
@@ -120,7 +120,6 @@ export function preparePieData(args) {
120
120
  else {
121
121
  y = y < 0 ? y - labelHeight : y;
122
122
  }
123
- x = Math.max(-boundsWidth / 2, x);
124
123
  return [x, y];
125
124
  };
126
125
  const getConnectorPoints = (angle) => {
@@ -148,11 +147,25 @@ export function preparePieData(args) {
148
147
  segment: relatedSegment.data,
149
148
  angle: midAngle,
150
149
  };
150
+ if (!allowOverlow) {
151
+ const labelLeftPosition = getLeftPosition(label);
152
+ const newMaxWidth = labelLeftPosition > 0
153
+ ? Math.min(boundsWidth / 2 - labelLeftPosition, labelWidth)
154
+ : Math.min(labelWidth - (-labelLeftPosition - boundsWidth / 2), labelWidth);
155
+ if (newMaxWidth !== label.maxWidth) {
156
+ label.maxWidth = Math.max(0, newMaxWidth);
157
+ }
158
+ }
151
159
  let overlap = false;
152
160
  if (prevLabel) {
153
161
  overlap = isLabelsOverlapping(prevLabel, label, dataLabels.padding);
162
+ const startAngle = relatedSegment.startAngle +
163
+ (relatedSegment.endAngle - relatedSegment.startAngle) / 2;
154
164
  if (overlap) {
155
- let shouldAdjustAngle = true;
165
+ let shouldAdjustAngle = !shouldStopLabelPlacement;
166
+ const connectorPoints = getConnectorPoints(startAngle);
167
+ const pointA = connectorPoints[0];
168
+ const pointB = connectorPoints[connectorPoints.length - 1];
156
169
  const step = Math.PI / 180;
157
170
  while (shouldAdjustAngle) {
158
171
  const newAngle = label.angle + step;
@@ -164,6 +177,11 @@ export function preparePieData(args) {
164
177
  const [newX, newY] = getLabelPosition(newAngle);
165
178
  label.x = newX;
166
179
  label.y = newY;
180
+ const inscribedAngle = getInscribedAngle(pointA, pointB, [newX, newY]);
181
+ if (inscribedAngle > 90) {
182
+ shouldAdjustAngle = false;
183
+ shouldStopLabelPlacement = true;
184
+ }
167
185
  if (!isLabelsOverlapping(prevLabel, label, dataLabels.padding)) {
168
186
  shouldAdjustAngle = false;
169
187
  overlap = false;
@@ -172,21 +190,8 @@ export function preparePieData(args) {
172
190
  }
173
191
  }
174
192
  }
175
- if (dataLabels.allowOverlap || !overlap) {
176
- const left = getLeftPosition(label);
177
- if (Math.abs(left) > boundsWidth / 2) {
178
- const overflow = Math.abs(left) - boundsWidth / 2;
179
- label.maxWidth = label.size.width - overflow;
180
- maxMissingWidth = Math.max(maxMissingWidth, overflow);
181
- }
182
- else {
183
- const right = left + label.size.width;
184
- if (right > boundsWidth / 2) {
185
- const overflow = right - boundsWidth / 2;
186
- label.maxWidth = label.size.width - overflow;
187
- maxMissingWidth = Math.max(maxMissingWidth, overflow);
188
- }
189
- }
193
+ const isLabelOverlapped = !dataLabels.allowOverlap && overlap;
194
+ if (!isLabelOverlapped && label.maxWidth > 0 && !shouldStopLabelPlacement) {
190
195
  if (shouldUseHtml) {
191
196
  htmlLabels.push({
192
197
  x: data.center[0] + label.x,
@@ -200,7 +205,7 @@ export function preparePieData(args) {
200
205
  labels.push(label);
201
206
  }
202
207
  const connector = {
203
- path: line(getConnectorPoints(midAngle)),
208
+ path: line(getConnectorPoints(label.angle)),
204
209
  color: relatedSegment.data.color,
205
210
  };
206
211
  connectors.push(connector);
@@ -218,43 +223,71 @@ export function preparePieData(args) {
218
223
  data,
219
224
  series: items,
220
225
  });
226
+ let maxLeftRightFreeSpace = Infinity;
227
+ let labelsOverflow = 0;
228
+ preparedLabels.labels.forEach((label) => {
229
+ const left = getLeftPosition(label);
230
+ let freeSpace = 0;
231
+ if (left < 0) {
232
+ freeSpace = boundsWidth / 2 - Math.abs(left);
233
+ }
234
+ else {
235
+ freeSpace = boundsWidth / 2 - (left + label.size.width);
236
+ }
237
+ maxLeftRightFreeSpace = Math.max(0, Math.min(maxLeftRightFreeSpace, freeSpace));
238
+ labelsOverflow = freeSpace < 0 ? Math.max(labelsOverflow, -freeSpace) : labelsOverflow;
239
+ });
221
240
  const segmentMaxRadius = Math.max(...data.segments.map((s) => s.data.radius));
222
- const topAdjustment = Math.min(data.center[1] - segmentMaxRadius, ...preparedLabels.labels.map((l) => l.y + data.center[1]), ...preparedLabels.htmlLabels.map((l) => l.y));
223
- const bottom = Math.max(data.center[1] + segmentMaxRadius, ...preparedLabels.labels.map((l) => l.y + data.center[1] + l.size.height), ...preparedLabels.htmlLabels.map((l) => l.y + l.size.height));
224
- if (topAdjustment > 0) {
241
+ if (labelsOverflow) {
225
242
  data.segments.forEach((s) => {
226
- const nextPossibleRadius = s.data.radius + topAdjustment / 2;
227
- s.data.radius = Math.min(nextPossibleRadius, maxRadius);
243
+ const neeSegmentRadius = Math.max(minRadius, s.data.radius - labelsOverflow);
244
+ s.data.radius = neeSegmentRadius;
228
245
  });
229
- data.center[1] -= topAdjustment / 2;
230
246
  }
231
- const bottomAdjustment = Math.floor(boundsHeight - bottom);
232
- if (bottomAdjustment > 0) {
233
- data.segments.forEach((s) => {
234
- const nextPossibleRadius = s.data.radius + bottomAdjustment / 2;
235
- s.data.radius = Math.min(nextPossibleRadius, maxRadius);
236
- });
237
- data.center[1] += bottomAdjustment / 2;
247
+ else {
248
+ let topFreeSpace = data.center[1] - segmentMaxRadius - haloSize;
249
+ if (preparedLabels.labels.length) {
250
+ const topSvgLabel = Math.max(0, ...preparedLabels.labels.map((l) => -l.y));
251
+ topFreeSpace = Math.min(topFreeSpace, data.center[1] - topSvgLabel);
252
+ }
253
+ if (preparedLabels.htmlLabels.length) {
254
+ const topHtmlLabel = Math.max(0, ...preparedLabels.htmlLabels.map((l) => l.y));
255
+ topFreeSpace = Math.min(topFreeSpace, topHtmlLabel);
256
+ }
257
+ let bottomFreeSpace = data.center[1] - segmentMaxRadius - haloSize;
258
+ if (preparedLabels.labels.length) {
259
+ const bottomSvgLabel = Math.max(0, ...preparedLabels.labels.map((l) => l.y + l.size.height));
260
+ bottomFreeSpace = Math.min(bottomFreeSpace, data.center[1] - bottomSvgLabel);
261
+ }
262
+ if (preparedLabels.htmlLabels.length) {
263
+ const bottomHtmlLabel = Math.max(0, ...preparedLabels.htmlLabels.map((l) => l.y + l.size.height));
264
+ bottomFreeSpace = Math.min(bottomFreeSpace, data.center[1] * 2 - bottomHtmlLabel);
265
+ }
266
+ const topAdjustment = Math.max(0, Math.min(topFreeSpace, maxLeftRightFreeSpace));
267
+ const bottomAdjustment = Math.max(0, Math.min(bottomFreeSpace, maxLeftRightFreeSpace));
268
+ if (topAdjustment && topAdjustment >= bottomAdjustment) {
269
+ data.segments.forEach((s) => {
270
+ const nextPossibleRadius = s.data.radius + (topAdjustment + bottomAdjustment) / 2;
271
+ s.data.radius = Math.min(nextPossibleRadius, maxRadius);
272
+ });
273
+ data.center[1] -= (topAdjustment - bottomAdjustment) / 2;
274
+ }
275
+ else if (bottomAdjustment) {
276
+ data.segments.forEach((s) => {
277
+ const nextPossibleRadius = s.data.radius + (topAdjustment + bottomAdjustment) / 2;
278
+ s.data.radius = Math.min(nextPossibleRadius, maxRadius);
279
+ });
280
+ data.center[1] += (bottomAdjustment - topAdjustment) / 2;
281
+ }
238
282
  }
239
283
  const { labels, htmlLabels, connectors } = prepareLabels({
240
284
  data,
241
285
  series: items,
286
+ allowOverlow: false,
242
287
  });
243
288
  data.labels = labels;
244
289
  data.htmlLabels = htmlLabels;
245
290
  data.connectors = connectors;
246
- if (maxMissingWidth > 0) {
247
- const { dataLabels } = items[0];
248
- if (dataLabels.enabled) {
249
- data.segments.forEach((s) => {
250
- s.data.radius = Math.max(minRadius, s.data.radius - maxMissingWidth);
251
- });
252
- const finalLabels = prepareLabels({ data, series: items });
253
- data.labels = finalLabels.labels;
254
- data.htmlLabels = finalLabels.htmlLabels;
255
- data.connectors = finalLabels.connectors;
256
- }
257
- }
258
291
  return data;
259
292
  });
260
293
  }
@@ -1,4 +1,14 @@
1
1
  import type { CurveFactory } from 'd3';
2
+ import type { PointPosition } from '../../../types';
2
3
  import type { PreparedPieData, SegmentData } from './types';
3
4
  export declare const pieGenerator: import("d3-shape").Pie<any, SegmentData>;
4
5
  export declare function getCurveFactory(data: PreparedPieData): CurveFactory | undefined;
6
+ /**
7
+ * Inscribed angle at vertex A (opposite side/chord BC): the angle between rays AB and AC.
8
+ *
9
+ * The order of B and C does not affect the result.
10
+ *
11
+ * @see: https://en.wikipedia.org/wiki/Inscribed_angle
12
+ * @returns The angle in degrees, in the range [0, 180].
13
+ */
14
+ export declare function getInscribedAngle(a: PointPosition, b: PointPosition, c: PointPosition): number;
@@ -13,3 +13,21 @@ export function getCurveFactory(data) {
13
13
  }
14
14
  return undefined;
15
15
  }
16
+ /**
17
+ * Inscribed angle at vertex A (opposite side/chord BC): the angle between rays AB and AC.
18
+ *
19
+ * The order of B and C does not affect the result.
20
+ *
21
+ * @see: https://en.wikipedia.org/wiki/Inscribed_angle
22
+ * @returns The angle in degrees, in the range [0, 180].
23
+ */
24
+ export function getInscribedAngle(a, b, c) {
25
+ const ux = b[0] - a[0];
26
+ const uy = b[1] - a[1];
27
+ const vx = c[0] - a[0];
28
+ const vy = c[1] - a[1];
29
+ const dot = ux * vx + uy * vy;
30
+ const cross = ux * vy - uy * vx;
31
+ const radians = Math.atan2(Math.abs(cross), dot);
32
+ return (radians * 180) / Math.PI;
33
+ }
@@ -1,16 +1,16 @@
1
- import { stratify, treemap, treemapBinary, treemapDice, treemapSlice, treemapSliceDice, treemapSquarify, } from 'd3';
1
+ import { ascending, descending, sort, stratify, treemap, treemapBinary, treemapDice, treemapSlice, treemapSliceDice, treemapSquarify, } from 'd3';
2
2
  import { LayoutAlgorithm } from '../../../constants';
3
3
  import { getLabelsSize } from '../../../utils';
4
4
  import { getFormattedValue } from '../../../utils/chart/format';
5
5
  const DEFAULT_PADDING = 1;
6
6
  function getLabels(args) {
7
- const { data, options: { html, padding, align }, } = args;
7
+ const { data, options: { html, padding, align, style }, } = args;
8
8
  return data.reduce((acc, d) => {
9
9
  const texts = Array.isArray(d.data.name) ? d.data.name : [d.data.name];
10
10
  texts.forEach((text, index) => {
11
11
  var _a;
12
12
  const label = getFormattedValue(Object.assign({ value: text }, args.options));
13
- const { maxHeight: lineHeight, maxWidth: labelMaxWidth } = (_a = getLabelsSize({ labels: [label], html })) !== null && _a !== void 0 ? _a : {};
13
+ const { maxHeight: lineHeight, maxWidth: labelMaxWidth } = (_a = getLabelsSize({ labels: [label], style, html })) !== null && _a !== void 0 ? _a : {};
14
14
  const left = d.x0 + padding;
15
15
  const right = d.x1 - padding;
16
16
  const spaceWidth = Math.max(0, right - left);
@@ -35,6 +35,10 @@ function getLabels(args) {
35
35
  break;
36
36
  }
37
37
  }
38
+ const bottom = y + lineHeight;
39
+ if (bottom > d.y1) {
40
+ return;
41
+ }
38
42
  const item = html
39
43
  ? {
40
44
  content: label,
@@ -57,7 +61,25 @@ function getLabels(args) {
57
61
  export function prepareTreemapData(args) {
58
62
  var _a;
59
63
  const { series, width, height } = args;
60
- const dataWithRootNode = getSeriesDataWithRootNode(series);
64
+ const parentNodeValues = {};
65
+ let dataWithRootNode = series.data.reduce((acc, d) => {
66
+ var _a, _b;
67
+ const dataChunk = Object.assign({}, d);
68
+ if (!dataChunk.parentId) {
69
+ dataChunk.parentId = series.id;
70
+ }
71
+ if (dataChunk.parentId) {
72
+ parentNodeValues[dataChunk.parentId] =
73
+ ((_a = parentNodeValues[dataChunk.parentId]) !== null && _a !== void 0 ? _a : 0) + ((_b = dataChunk.value) !== null && _b !== void 0 ? _b : 0);
74
+ }
75
+ acc.push(dataChunk);
76
+ return acc;
77
+ }, [{ name: series.name, id: series.id }]);
78
+ if (series.sorting.enabled) {
79
+ const getSortingValue = (d) => { var _a, _b; return (_a = d.value) !== null && _a !== void 0 ? _a : parentNodeValues[(_b = d.id) !== null && _b !== void 0 ? _b : '']; };
80
+ const comparator = series.sorting.direction === 'desc' ? descending : ascending;
81
+ dataWithRootNode = sort(dataWithRootNode, (a, b) => comparator(getSortingValue(a), getSortingValue(b)));
82
+ }
61
83
  const hierarchy = stratify()
62
84
  .id((d) => {
63
85
  if (d.id) {
@@ -102,7 +124,7 @@ export function prepareTreemapData(args) {
102
124
  const { html, style: dataLabelsStyle } = series.dataLabels;
103
125
  const labels = getLabels({ data: leaves, options: series.dataLabels });
104
126
  if (html) {
105
- const htmlItems = labels.map((l) => (Object.assign({ style: dataLabelsStyle }, l)));
127
+ const htmlItems = labels.map((l) => (Object.assign({ style: Object.assign(Object.assign({}, dataLabelsStyle), { maxWidth: l.size.width, maxHeight: l.size.height, overflow: 'hidden' }) }, l)));
106
128
  htmlElements.push(...htmlItems);
107
129
  }
108
130
  else {
@@ -111,13 +133,3 @@ export function prepareTreemapData(args) {
111
133
  }
112
134
  return { labelData, leaves, series, htmlElements };
113
135
  }
114
- function getSeriesDataWithRootNode(series) {
115
- return series.data.reduce((acc, d) => {
116
- const dataChunk = Object.assign({}, d);
117
- if (!dataChunk.parentId) {
118
- dataChunk.parentId = series.id;
119
- }
120
- acc.push(dataChunk);
121
- return acc;
122
- }, [{ name: series.name, id: series.id }]);
123
- }
@@ -43,4 +43,15 @@ export interface TreemapSeries<T = MeaningfulAny> extends BaseSeries {
43
43
  /** Horizontal alignment of the data label inside the tile. */
44
44
  align?: 'left' | 'center' | 'right';
45
45
  };
46
+ /** Data sorting settings (affects the order in which blocks are displayed inside the chart).
47
+ * If the option is not specified, the data is displayed in the order defined by the user. */
48
+ sorting?: {
49
+ /** Enable or disable sorting. */
50
+ enabled?: boolean;
51
+ /** The sorting direction.
52
+ *
53
+ * @default: 'desc'
54
+ */
55
+ direction?: 'asc' | 'desc';
56
+ };
46
57
  }
@@ -64,7 +64,7 @@ function renderLabels(selection, { labels, style = {}, attrs = {}, }) {
64
64
  return text;
65
65
  }
66
66
  export function getLabelsSize({ labels, style, rotation, html, }) {
67
- var _a, _b, _c;
67
+ var _a, _b, _c, _d, _e;
68
68
  if (!labels.filter(Boolean).length) {
69
69
  return { maxHeight: 0, maxWidth: 0 };
70
70
  }
@@ -74,7 +74,12 @@ export function getLabelsSize({ labels, style, rotation, html, }) {
74
74
  const result = { maxHeight: 0, maxWidth: 0 };
75
75
  let labelWrapper;
76
76
  if (html) {
77
- labelWrapper = container.append('div').style('position', 'absolute').node();
77
+ labelWrapper = container
78
+ .append('div')
79
+ .style('position', 'absolute')
80
+ .style('font-size', (_a = style === null || style === void 0 ? void 0 : style.fontSize) !== null && _a !== void 0 ? _a : '')
81
+ .style('font-weight', (_b = style === null || style === void 0 ? void 0 : style.fontWeight) !== null && _b !== void 0 ? _b : '')
82
+ .node();
78
83
  const { height, width } = labels.reduce((acc, l) => {
79
84
  var _a, _b;
80
85
  if (labelWrapper) {
@@ -97,9 +102,9 @@ export function getLabelsSize({ labels, style, rotation, html, }) {
97
102
  .attr('text-anchor', rotation > 0 ? 'start' : 'end')
98
103
  .style('transform', `rotate(${rotation}deg)`);
99
104
  }
100
- const rect = (_a = svg.select('g').node()) === null || _a === void 0 ? void 0 : _a.getBoundingClientRect();
101
- result.maxWidth = (_b = rect === null || rect === void 0 ? void 0 : rect.width) !== null && _b !== void 0 ? _b : 0;
102
- result.maxHeight = (_c = rect === null || rect === void 0 ? void 0 : rect.height) !== null && _c !== void 0 ? _c : 0;
105
+ const rect = (_c = svg.select('g').node()) === null || _c === void 0 ? void 0 : _c.getBoundingClientRect();
106
+ result.maxWidth = (_d = rect === null || rect === void 0 ? void 0 : rect.width) !== null && _d !== void 0 ? _d : 0;
107
+ result.maxHeight = (_e = rect === null || rect === void 0 ? void 0 : rect.height) !== null && _e !== void 0 ? _e : 0;
103
108
  }
104
109
  container.remove();
105
110
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gravity-ui/charts",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "React component used to render charts",
5
5
  "license": "MIT",
6
6
  "main": "dist/cjs/index.js",