@gravity-ui/charts 1.45.0 → 1.46.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.
@@ -256,7 +256,7 @@ export const ChartInner = (props) => {
256
256
  React.createElement("rect", { x: 0, y: 0, width: boundsWidth, height: boundsHeight })),
257
257
  React.createElement("clipPath", { id: getClipPathIdByBounds({ clipPathId, bounds: 'horizontal' }) },
258
258
  React.createElement("rect", { x: 0, y: -boundsHeight, width: boundsWidth, height: boundsHeight * 3 }))),
259
- preparedTitle && React.createElement(Title, Object.assign({}, preparedTitle)),
259
+ preparedTitle && React.createElement(Title, Object.assign({}, preparedTitle, { htmlLayout: htmlLayout })),
260
260
  React.createElement("g", { transform: `translate(0, ${boundsOffsetTop})` }, preparedSplit === null || preparedSplit === void 0 ? void 0 : preparedSplit.plots.map((plot, index) => {
261
261
  return React.createElement(PlotTitle, { key: `plot-${index}`, title: plot.title });
262
262
  })),
@@ -285,7 +285,7 @@ export const ChartInner = (props) => {
285
285
  // when starting to select an area, the tooltip remains in the position where the selection began
286
286
  onPointerMove: throttledHandlePointerMove, onPointerLeave: handlePointerLeave, onTouchStart: throttledHandleTouchMove, onTouchMove: throttledHandleTouchMove, onClick: handleChartClick }, initialized ? chartContent : null),
287
287
  React.createElement("div", { className: b('html-layer'), ref: setHtmlLayout, style: {
288
- '--g-html-layout-transform': `translate(${boundsOffsetLeft}px, ${boundsOffsetTop}px)`,
288
+ '--g-html-layout-plot-transform': `translate(${boundsOffsetLeft}px, ${boundsOffsetTop}px)`,
289
289
  } }),
290
290
  Object.keys(zoomState).length > 0 && (preparedChart === null || preparedChart === void 0 ? void 0 : preparedChart.zoom) && (React.createElement(Button, { className: b('reset-zoom-button'), onClick: () => {
291
291
  var _a;
@@ -10,6 +10,6 @@
10
10
  .gcharts-chart__html-layer {
11
11
  display: contents;
12
12
  }
13
- .gcharts-chart__html-layer-item {
14
- transform: var(--g-html-layout-transform);
13
+ .gcharts-chart__html-layer-item_plot {
14
+ transform: var(--g-html-layout-plot-transform);
15
15
  }
@@ -53,6 +53,7 @@ export function useChartInnerProps(props) {
53
53
  const preparedTitle = await getPreparedTitle({
54
54
  title: data.title,
55
55
  chartWidth: width,
56
+ chartHeight: height,
56
57
  chartMargin: (_a = data.chart) === null || _a === void 0 ? void 0 : _a.margin,
57
58
  });
58
59
  const preparedChart = getPreparedChart({
@@ -203,7 +204,6 @@ export function useChartInnerProps(props) {
203
204
  getYAxisWidth,
204
205
  legendConfig,
205
206
  });
206
- //end
207
207
  const newStateValue = {
208
208
  allPreparedSeries,
209
209
  boundsHeight,
@@ -1,7 +1,8 @@
1
1
  import type { PreparedTitle } from '../../../hooks/types';
2
2
  import type { ChartData, ChartMargin } from '../../../types';
3
- export declare const getPreparedTitle: ({ title, chartWidth, chartMargin, }: {
3
+ export declare const getPreparedTitle: ({ title, chartWidth, chartHeight, chartMargin, }: {
4
4
  title: ChartData["title"];
5
5
  chartWidth: number;
6
+ chartHeight: number;
6
7
  chartMargin?: Partial<ChartMargin>;
7
8
  }) => Promise<PreparedTitle | undefined>;
@@ -1,26 +1,64 @@
1
- import get from 'lodash/get';
2
- import { getTextSizeFn, getTextWithElipsis, wrapText } from '../../../core/utils';
1
+ import { calculateNumericProperty, getLabelsSize, getTextSizeFn, getTextWithElipsis, wrapText, } from '../../../core/utils';
3
2
  const DEFAULT_TITLE_FONT_SIZE = '15px';
4
3
  const DEFAULT_TITLE_MARGIN = 10;
5
- export const getPreparedTitle = async ({ title, chartWidth, chartMargin, }) => {
6
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
4
+ const DEFAULT_TITLE_MAX_HEIGHT = '50%';
5
+ export const getPreparedTitle = async ({ title, chartWidth, chartHeight, chartMargin, }) => {
6
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
7
+ const titleText = title === null || title === void 0 ? void 0 : title.text;
8
+ if (!titleText) {
9
+ return undefined;
10
+ }
7
11
  const chartMarginTop = (_a = chartMargin === null || chartMargin === void 0 ? void 0 : chartMargin.top) !== null && _a !== void 0 ? _a : 0;
8
12
  const chartMarginLeft = (_b = chartMargin === null || chartMargin === void 0 ? void 0 : chartMargin.left) !== null && _b !== void 0 ? _b : 0;
9
13
  const chartMarginRight = (_c = chartMargin === null || chartMargin === void 0 ? void 0 : chartMargin.right) !== null && _c !== void 0 ? _c : 0;
10
- const titleText = get(title, 'text');
11
14
  const titleStyle = {
12
15
  fontSize: (_e = (_d = title === null || title === void 0 ? void 0 : title.style) === null || _d === void 0 ? void 0 : _d.fontSize) !== null && _e !== void 0 ? _e : DEFAULT_TITLE_FONT_SIZE,
13
16
  fontWeight: (_g = (_f = title === null || title === void 0 ? void 0 : title.style) === null || _f === void 0 ? void 0 : _f.fontWeight) !== null && _g !== void 0 ? _g : 'var(--g-text-subheader-font-weight)',
14
17
  fontColor: (_j = (_h = title === null || title === void 0 ? void 0 : title.style) === null || _h === void 0 ? void 0 : _h.fontColor) !== null && _j !== void 0 ? _j : 'var(--g-color-text-primary)',
15
18
  };
16
- if (!titleText) {
17
- return undefined;
19
+ const usableWidth = chartWidth - chartMarginLeft - chartMarginRight;
20
+ const titleMargin = (_k = title === null || title === void 0 ? void 0 : title.margin) !== null && _k !== void 0 ? _k : DEFAULT_TITLE_MARGIN;
21
+ if (title === null || title === void 0 ? void 0 : title.html) {
22
+ const titleSize = await getLabelsSize({
23
+ labels: [titleText],
24
+ style: Object.assign(Object.assign({}, titleStyle), { maxWidth: `${usableWidth}px` }),
25
+ html: true,
26
+ });
27
+ const resolvedMaxHeight = calculateNumericProperty({
28
+ value: (_l = title.maxHeight) !== null && _l !== void 0 ? _l : DEFAULT_TITLE_MAX_HEIGHT,
29
+ base: chartHeight,
30
+ });
31
+ const titleHeight = resolvedMaxHeight === undefined
32
+ ? titleSize.maxHeight
33
+ : Math.min(titleSize.maxHeight, resolvedMaxHeight);
34
+ const qa = title === null || title === void 0 ? void 0 : title.qa;
35
+ const qaAttr = qa ? ` data-qa="${qa}"` : '';
36
+ const maxHeightStyle = resolvedMaxHeight === undefined ? '' : ` max-height: ${resolvedMaxHeight}px;`;
37
+ const htmlContent = `<div${qaAttr} style="max-width: ${usableWidth}px; overflow: hidden;${maxHeightStyle}">${titleText}</div>`;
38
+ const titleWidth = Math.min(titleSize.maxWidth, usableWidth);
39
+ return {
40
+ text: titleText,
41
+ style: titleStyle,
42
+ height: titleHeight,
43
+ margin: titleMargin,
44
+ qa,
45
+ html: true,
46
+ htmlElements: [
47
+ {
48
+ x: chartMarginLeft + (usableWidth - titleWidth) / 2,
49
+ y: chartMarginTop,
50
+ content: htmlContent,
51
+ size: { width: titleWidth, height: titleHeight },
52
+ style: titleStyle,
53
+ scope: 'chart',
54
+ },
55
+ ],
56
+ };
18
57
  }
58
+ const xCenter = chartMarginLeft + usableWidth / 2;
19
59
  const getTitleTextSize = getTextSizeFn({ style: titleStyle });
20
- const maxRowCount = (_k = title === null || title === void 0 ? void 0 : title.maxRowCount) !== null && _k !== void 0 ? _k : 1;
60
+ const maxRowCount = (_m = title === null || title === void 0 ? void 0 : title.maxRowCount) !== null && _m !== void 0 ? _m : 1;
21
61
  const contentRows = [];
22
- const usableWidth = chartWidth - chartMarginLeft - chartMarginRight;
23
- const xCenter = chartMarginLeft + usableWidth / 2;
24
62
  if (maxRowCount > 1) {
25
63
  let titleTextRows = await wrapText({
26
64
  text: titleText,
@@ -33,7 +71,7 @@ export const getPreparedTitle = async ({ title, chartWidth, chartMargin, }) => {
33
71
  acc.push(row);
34
72
  }
35
73
  else {
36
- acc[maxRowCount - 1].text += row.text;
74
+ acc[maxRowCount - 1] = Object.assign(Object.assign({}, acc[maxRowCount - 1]), { text: acc[maxRowCount - 1].text + row.text });
37
75
  }
38
76
  return acc;
39
77
  }, []);
@@ -71,12 +109,11 @@ export const getPreparedTitle = async ({ title, chartWidth, chartMargin, }) => {
71
109
  });
72
110
  }
73
111
  const totalTextHeight = contentRows.reduce((acc, row) => acc + row.size.height, 0);
74
- const titleHeight = totalTextHeight;
75
112
  return {
76
113
  text: titleText,
77
114
  style: titleStyle,
78
- height: titleHeight,
79
- margin: (_l = title === null || title === void 0 ? void 0 : title.margin) !== null && _l !== void 0 ? _l : DEFAULT_TITLE_MARGIN,
115
+ height: totalTextHeight,
116
+ margin: titleMargin,
80
117
  qa: title === null || title === void 0 ? void 0 : title.qa,
81
118
  contentRows,
82
119
  };
@@ -1,5 +1,7 @@
1
1
  import React from 'react';
2
2
  import type { PreparedTitle } from '../../hooks';
3
- type Props = PreparedTitle;
4
- export declare const Title: (props: Props) => React.JSX.Element;
3
+ type Props = PreparedTitle & {
4
+ htmlLayout?: HTMLElement | null;
5
+ };
6
+ export declare const Title: (props: Props) => React.JSX.Element | null;
5
7
  export {};
@@ -1,9 +1,16 @@
1
1
  import React from 'react';
2
+ import { HtmlLayer } from '../../hooks/useShapes/HtmlLayer';
2
3
  export const Title = (props) => {
3
- const { style, qa, contentRows } = props;
4
+ const { style, qa, contentRows, html, htmlElements, htmlLayout } = props;
5
+ if (html) {
6
+ if (!htmlLayout || !htmlElements) {
7
+ return null;
8
+ }
9
+ return React.createElement(HtmlLayer, { htmlLayout: htmlLayout, preparedData: { htmlElements } });
10
+ }
4
11
  return (React.createElement("text", { dominantBaseline: "hanging", textAnchor: "middle", style: {
5
12
  fill: style === null || style === void 0 ? void 0 : style.fontColor,
6
13
  fontSize: style === null || style === void 0 ? void 0 : style.fontSize,
7
14
  fontWeight: style === null || style === void 0 ? void 0 : style.fontWeight,
8
- }, "data-qa": qa }, contentRows.map((row, i) => (React.createElement("tspan", { key: i, x: row.x, y: row.y, dominantBaseline: "hanging", dangerouslySetInnerHTML: { __html: row.text } })))));
15
+ }, "data-qa": qa }, contentRows === null || contentRows === void 0 ? void 0 : contentRows.map((row, i) => (React.createElement("tspan", { key: i, x: row.x, y: row.y, dominantBaseline: "hanging", dangerouslySetInnerHTML: { __html: row.text } })))));
9
16
  };
@@ -5,6 +5,7 @@ export interface ChartTitle {
5
5
  /**
6
6
  * Maximum number of text rows. If the text exceeds this limit, it is truncated with an ellipsis.
7
7
  * Default: 1
8
+ * Not applicable when `html: true` — HTML content manages its own layout.
8
9
  */
9
10
  maxRowCount?: number;
10
11
  /**
@@ -17,4 +18,21 @@ export interface ChartTitle {
17
18
  * It is assigned as a data-qa attribute to an element.
18
19
  */
19
20
  qa?: string;
21
+ /**
22
+ * Enables HTML rendering for the chart title.
23
+ * When true, the title is rendered as an HTML element on top of the SVG
24
+ * instead of an SVG text node. This allows using arbitrary HTML tags
25
+ * (e.g. links, styled spans) that cannot be embedded in SVG.
26
+ * The element will be displayed outside the box of the SVG element.
27
+ */
28
+ html?: boolean;
29
+ /**
30
+ * Maximum height of the title area. Accepts a pixel value (`number` or `"100px"`)
31
+ * or a percentage string (`"50%"`) relative to the full chart height.
32
+ * When the title content exceeds this height, it is clipped.
33
+ * Only applicable when `html: true`.
34
+ *
35
+ * Default: 50%
36
+ */
37
+ maxHeight?: string | number;
20
38
  }
@@ -1,5 +1,6 @@
1
1
  import type { TextRowData } from '../components/types';
2
- import type { ChartBrush, ChartData, ChartMargin, ChartTitle, ChartZoom, DeepRequired } from '../types';
2
+ import type { BaseTextStyle, ChartBrush, ChartData, ChartMargin, ChartTitle, ChartZoom, DeepRequired } from '../types';
3
+ import type { HtmlItem } from '../types/chart-ui';
3
4
  export type PreparedZoom = DeepRequired<Omit<ChartZoom, 'enabled' | 'brush'>> & DeepRequired<{
4
5
  brush: ChartBrush;
5
6
  }>;
@@ -7,10 +8,12 @@ export type PreparedChart = {
7
8
  margin: ChartMargin;
8
9
  zoom: PreparedZoom | null;
9
10
  };
10
- export type PreparedTitle = Omit<ChartTitle, 'margin'> & {
11
+ export type PreparedTitle = Omit<ChartTitle, 'margin' | 'style'> & {
11
12
  height: number;
12
13
  margin: number;
13
- contentRows: TextRowData[];
14
+ style: BaseTextStyle;
15
+ contentRows?: TextRowData[];
16
+ htmlElements?: HtmlItem[];
14
17
  };
15
18
  export type PreparedTooltip = ChartData['tooltip'] & {
16
19
  enabled: boolean;
@@ -19,8 +19,9 @@ export const HtmlLayer = (props) => {
19
19
  return null;
20
20
  }
21
21
  return (React.createElement(Portal, { container: htmlLayout }, items.map((item, index) => {
22
- var _a, _b, _c;
23
- const style = Object.assign(Object.assign({}, item.style), { color: (_b = (_a = item.style) === null || _a === void 0 ? void 0 : _a.color) !== null && _b !== void 0 ? _b : (_c = item.style) === null || _c === void 0 ? void 0 : _c.fontColor, position: 'absolute', left: item.x, top: item.y });
24
- return (React.createElement("div", { className: b('html-layer-item'), key: index, dangerouslySetInnerHTML: { __html: item.content }, style: style }));
22
+ var _a, _b, _c, _d;
23
+ const scope = (_a = item.scope) !== null && _a !== void 0 ? _a : 'plot';
24
+ const style = Object.assign(Object.assign({}, item.style), { color: (_c = (_b = item.style) === null || _b === void 0 ? void 0 : _b.color) !== null && _c !== void 0 ? _c : (_d = item.style) === null || _d === void 0 ? void 0 : _d.fontColor, position: 'absolute', left: item.x, top: item.y });
25
+ return (React.createElement("div", { className: b('html-layer-item', { plot: scope === 'plot' }), key: index, dangerouslySetInnerHTML: { __html: item.content }, style: style }));
25
26
  })));
26
27
  };
@@ -81,8 +81,8 @@ export const LineSeriesShapes = (args) => {
81
81
  function handleShapeHover(data) {
82
82
  hoveredDataRef.current = data;
83
83
  const selected = (data === null || data === void 0 ? void 0 : data.filter((d) => d.series.type === 'line')) || [];
84
+ const selectedDataItems = selected.map((d) => d.data);
84
85
  const selectedSeriesIds = selected.map((d) => { var _a; return (_a = d.series) === null || _a === void 0 ? void 0 : _a.id; });
85
- const closestChunk = selected.find((d) => d.closest);
86
86
  lineSelection.datum((d, index, list) => {
87
87
  const elementSelection = select(list[index]);
88
88
  const hovered = Boolean(hoverEnabled && selectedSeriesIds.includes(d.id));
@@ -118,7 +118,7 @@ export const LineSeriesShapes = (args) => {
118
118
  });
119
119
  markerSelection.datum((d, index, list) => {
120
120
  const elementSelection = select(list[index]);
121
- const hovered = Boolean(hoverEnabled && d.point.data === (closestChunk === null || closestChunk === void 0 ? void 0 : closestChunk.data));
121
+ const hovered = Boolean(hoverEnabled && selectedDataItems.includes(d.point.data));
122
122
  if (d.hovered !== hovered) {
123
123
  d.hovered = hovered;
124
124
  elementSelection.attr('visibility', getMarkerVisibility(d));
@@ -141,7 +141,7 @@ export const LineSeriesShapes = (args) => {
141
141
  hoverMarkersSvgElement.selectAll('*').remove();
142
142
  if (hoverEnabled && selected.length > 0) {
143
143
  const hoverOnlyMarkers = [];
144
- for (const chunk of selected.filter((c) => c.closest)) {
144
+ for (const chunk of selected) {
145
145
  const seriesData = preparedData.find((pd) => pd.id === chunk.series.id);
146
146
  if (!seriesData) {
147
147
  continue;
@@ -24,6 +24,8 @@ export interface HtmlItem {
24
24
  height: number;
25
25
  };
26
26
  style?: BaseTextStyle & React.CSSProperties;
27
+ /** Coordinate space for positioning: 'plot' uses the plot area origin, 'chart' uses the full chart origin. Defaults to 'plot'. */
28
+ scope?: 'plot' | 'chart';
27
29
  }
28
30
  export interface ShapeDataWithLabels {
29
31
  svgLabels: LabelData[];
@@ -256,7 +256,7 @@ export const ChartInner = (props) => {
256
256
  React.createElement("rect", { x: 0, y: 0, width: boundsWidth, height: boundsHeight })),
257
257
  React.createElement("clipPath", { id: getClipPathIdByBounds({ clipPathId, bounds: 'horizontal' }) },
258
258
  React.createElement("rect", { x: 0, y: -boundsHeight, width: boundsWidth, height: boundsHeight * 3 }))),
259
- preparedTitle && React.createElement(Title, Object.assign({}, preparedTitle)),
259
+ preparedTitle && React.createElement(Title, Object.assign({}, preparedTitle, { htmlLayout: htmlLayout })),
260
260
  React.createElement("g", { transform: `translate(0, ${boundsOffsetTop})` }, preparedSplit === null || preparedSplit === void 0 ? void 0 : preparedSplit.plots.map((plot, index) => {
261
261
  return React.createElement(PlotTitle, { key: `plot-${index}`, title: plot.title });
262
262
  })),
@@ -285,7 +285,7 @@ export const ChartInner = (props) => {
285
285
  // when starting to select an area, the tooltip remains in the position where the selection began
286
286
  onPointerMove: throttledHandlePointerMove, onPointerLeave: handlePointerLeave, onTouchStart: throttledHandleTouchMove, onTouchMove: throttledHandleTouchMove, onClick: handleChartClick }, initialized ? chartContent : null),
287
287
  React.createElement("div", { className: b('html-layer'), ref: setHtmlLayout, style: {
288
- '--g-html-layout-transform': `translate(${boundsOffsetLeft}px, ${boundsOffsetTop}px)`,
288
+ '--g-html-layout-plot-transform': `translate(${boundsOffsetLeft}px, ${boundsOffsetTop}px)`,
289
289
  } }),
290
290
  Object.keys(zoomState).length > 0 && (preparedChart === null || preparedChart === void 0 ? void 0 : preparedChart.zoom) && (React.createElement(Button, { className: b('reset-zoom-button'), onClick: () => {
291
291
  var _a;
@@ -10,6 +10,6 @@
10
10
  .gcharts-chart__html-layer {
11
11
  display: contents;
12
12
  }
13
- .gcharts-chart__html-layer-item {
14
- transform: var(--g-html-layout-transform);
13
+ .gcharts-chart__html-layer-item_plot {
14
+ transform: var(--g-html-layout-plot-transform);
15
15
  }
@@ -53,6 +53,7 @@ export function useChartInnerProps(props) {
53
53
  const preparedTitle = await getPreparedTitle({
54
54
  title: data.title,
55
55
  chartWidth: width,
56
+ chartHeight: height,
56
57
  chartMargin: (_a = data.chart) === null || _a === void 0 ? void 0 : _a.margin,
57
58
  });
58
59
  const preparedChart = getPreparedChart({
@@ -203,7 +204,6 @@ export function useChartInnerProps(props) {
203
204
  getYAxisWidth,
204
205
  legendConfig,
205
206
  });
206
- //end
207
207
  const newStateValue = {
208
208
  allPreparedSeries,
209
209
  boundsHeight,
@@ -1,7 +1,8 @@
1
1
  import type { PreparedTitle } from '../../../hooks/types';
2
2
  import type { ChartData, ChartMargin } from '../../../types';
3
- export declare const getPreparedTitle: ({ title, chartWidth, chartMargin, }: {
3
+ export declare const getPreparedTitle: ({ title, chartWidth, chartHeight, chartMargin, }: {
4
4
  title: ChartData["title"];
5
5
  chartWidth: number;
6
+ chartHeight: number;
6
7
  chartMargin?: Partial<ChartMargin>;
7
8
  }) => Promise<PreparedTitle | undefined>;
@@ -1,26 +1,64 @@
1
- import get from 'lodash/get';
2
- import { getTextSizeFn, getTextWithElipsis, wrapText } from '../../../core/utils';
1
+ import { calculateNumericProperty, getLabelsSize, getTextSizeFn, getTextWithElipsis, wrapText, } from '../../../core/utils';
3
2
  const DEFAULT_TITLE_FONT_SIZE = '15px';
4
3
  const DEFAULT_TITLE_MARGIN = 10;
5
- export const getPreparedTitle = async ({ title, chartWidth, chartMargin, }) => {
6
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
4
+ const DEFAULT_TITLE_MAX_HEIGHT = '50%';
5
+ export const getPreparedTitle = async ({ title, chartWidth, chartHeight, chartMargin, }) => {
6
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
7
+ const titleText = title === null || title === void 0 ? void 0 : title.text;
8
+ if (!titleText) {
9
+ return undefined;
10
+ }
7
11
  const chartMarginTop = (_a = chartMargin === null || chartMargin === void 0 ? void 0 : chartMargin.top) !== null && _a !== void 0 ? _a : 0;
8
12
  const chartMarginLeft = (_b = chartMargin === null || chartMargin === void 0 ? void 0 : chartMargin.left) !== null && _b !== void 0 ? _b : 0;
9
13
  const chartMarginRight = (_c = chartMargin === null || chartMargin === void 0 ? void 0 : chartMargin.right) !== null && _c !== void 0 ? _c : 0;
10
- const titleText = get(title, 'text');
11
14
  const titleStyle = {
12
15
  fontSize: (_e = (_d = title === null || title === void 0 ? void 0 : title.style) === null || _d === void 0 ? void 0 : _d.fontSize) !== null && _e !== void 0 ? _e : DEFAULT_TITLE_FONT_SIZE,
13
16
  fontWeight: (_g = (_f = title === null || title === void 0 ? void 0 : title.style) === null || _f === void 0 ? void 0 : _f.fontWeight) !== null && _g !== void 0 ? _g : 'var(--g-text-subheader-font-weight)',
14
17
  fontColor: (_j = (_h = title === null || title === void 0 ? void 0 : title.style) === null || _h === void 0 ? void 0 : _h.fontColor) !== null && _j !== void 0 ? _j : 'var(--g-color-text-primary)',
15
18
  };
16
- if (!titleText) {
17
- return undefined;
19
+ const usableWidth = chartWidth - chartMarginLeft - chartMarginRight;
20
+ const titleMargin = (_k = title === null || title === void 0 ? void 0 : title.margin) !== null && _k !== void 0 ? _k : DEFAULT_TITLE_MARGIN;
21
+ if (title === null || title === void 0 ? void 0 : title.html) {
22
+ const titleSize = await getLabelsSize({
23
+ labels: [titleText],
24
+ style: Object.assign(Object.assign({}, titleStyle), { maxWidth: `${usableWidth}px` }),
25
+ html: true,
26
+ });
27
+ const resolvedMaxHeight = calculateNumericProperty({
28
+ value: (_l = title.maxHeight) !== null && _l !== void 0 ? _l : DEFAULT_TITLE_MAX_HEIGHT,
29
+ base: chartHeight,
30
+ });
31
+ const titleHeight = resolvedMaxHeight === undefined
32
+ ? titleSize.maxHeight
33
+ : Math.min(titleSize.maxHeight, resolvedMaxHeight);
34
+ const qa = title === null || title === void 0 ? void 0 : title.qa;
35
+ const qaAttr = qa ? ` data-qa="${qa}"` : '';
36
+ const maxHeightStyle = resolvedMaxHeight === undefined ? '' : ` max-height: ${resolvedMaxHeight}px;`;
37
+ const htmlContent = `<div${qaAttr} style="max-width: ${usableWidth}px; overflow: hidden;${maxHeightStyle}">${titleText}</div>`;
38
+ const titleWidth = Math.min(titleSize.maxWidth, usableWidth);
39
+ return {
40
+ text: titleText,
41
+ style: titleStyle,
42
+ height: titleHeight,
43
+ margin: titleMargin,
44
+ qa,
45
+ html: true,
46
+ htmlElements: [
47
+ {
48
+ x: chartMarginLeft + (usableWidth - titleWidth) / 2,
49
+ y: chartMarginTop,
50
+ content: htmlContent,
51
+ size: { width: titleWidth, height: titleHeight },
52
+ style: titleStyle,
53
+ scope: 'chart',
54
+ },
55
+ ],
56
+ };
18
57
  }
58
+ const xCenter = chartMarginLeft + usableWidth / 2;
19
59
  const getTitleTextSize = getTextSizeFn({ style: titleStyle });
20
- const maxRowCount = (_k = title === null || title === void 0 ? void 0 : title.maxRowCount) !== null && _k !== void 0 ? _k : 1;
60
+ const maxRowCount = (_m = title === null || title === void 0 ? void 0 : title.maxRowCount) !== null && _m !== void 0 ? _m : 1;
21
61
  const contentRows = [];
22
- const usableWidth = chartWidth - chartMarginLeft - chartMarginRight;
23
- const xCenter = chartMarginLeft + usableWidth / 2;
24
62
  if (maxRowCount > 1) {
25
63
  let titleTextRows = await wrapText({
26
64
  text: titleText,
@@ -33,7 +71,7 @@ export const getPreparedTitle = async ({ title, chartWidth, chartMargin, }) => {
33
71
  acc.push(row);
34
72
  }
35
73
  else {
36
- acc[maxRowCount - 1].text += row.text;
74
+ acc[maxRowCount - 1] = Object.assign(Object.assign({}, acc[maxRowCount - 1]), { text: acc[maxRowCount - 1].text + row.text });
37
75
  }
38
76
  return acc;
39
77
  }, []);
@@ -71,12 +109,11 @@ export const getPreparedTitle = async ({ title, chartWidth, chartMargin, }) => {
71
109
  });
72
110
  }
73
111
  const totalTextHeight = contentRows.reduce((acc, row) => acc + row.size.height, 0);
74
- const titleHeight = totalTextHeight;
75
112
  return {
76
113
  text: titleText,
77
114
  style: titleStyle,
78
- height: titleHeight,
79
- margin: (_l = title === null || title === void 0 ? void 0 : title.margin) !== null && _l !== void 0 ? _l : DEFAULT_TITLE_MARGIN,
115
+ height: totalTextHeight,
116
+ margin: titleMargin,
80
117
  qa: title === null || title === void 0 ? void 0 : title.qa,
81
118
  contentRows,
82
119
  };
@@ -1,5 +1,7 @@
1
1
  import React from 'react';
2
2
  import type { PreparedTitle } from '../../hooks';
3
- type Props = PreparedTitle;
4
- export declare const Title: (props: Props) => React.JSX.Element;
3
+ type Props = PreparedTitle & {
4
+ htmlLayout?: HTMLElement | null;
5
+ };
6
+ export declare const Title: (props: Props) => React.JSX.Element | null;
5
7
  export {};
@@ -1,9 +1,16 @@
1
1
  import React from 'react';
2
+ import { HtmlLayer } from '../../hooks/useShapes/HtmlLayer';
2
3
  export const Title = (props) => {
3
- const { style, qa, contentRows } = props;
4
+ const { style, qa, contentRows, html, htmlElements, htmlLayout } = props;
5
+ if (html) {
6
+ if (!htmlLayout || !htmlElements) {
7
+ return null;
8
+ }
9
+ return React.createElement(HtmlLayer, { htmlLayout: htmlLayout, preparedData: { htmlElements } });
10
+ }
4
11
  return (React.createElement("text", { dominantBaseline: "hanging", textAnchor: "middle", style: {
5
12
  fill: style === null || style === void 0 ? void 0 : style.fontColor,
6
13
  fontSize: style === null || style === void 0 ? void 0 : style.fontSize,
7
14
  fontWeight: style === null || style === void 0 ? void 0 : style.fontWeight,
8
- }, "data-qa": qa }, contentRows.map((row, i) => (React.createElement("tspan", { key: i, x: row.x, y: row.y, dominantBaseline: "hanging", dangerouslySetInnerHTML: { __html: row.text } })))));
15
+ }, "data-qa": qa }, contentRows === null || contentRows === void 0 ? void 0 : contentRows.map((row, i) => (React.createElement("tspan", { key: i, x: row.x, y: row.y, dominantBaseline: "hanging", dangerouslySetInnerHTML: { __html: row.text } })))));
9
16
  };
@@ -5,6 +5,7 @@ export interface ChartTitle {
5
5
  /**
6
6
  * Maximum number of text rows. If the text exceeds this limit, it is truncated with an ellipsis.
7
7
  * Default: 1
8
+ * Not applicable when `html: true` — HTML content manages its own layout.
8
9
  */
9
10
  maxRowCount?: number;
10
11
  /**
@@ -17,4 +18,21 @@ export interface ChartTitle {
17
18
  * It is assigned as a data-qa attribute to an element.
18
19
  */
19
20
  qa?: string;
21
+ /**
22
+ * Enables HTML rendering for the chart title.
23
+ * When true, the title is rendered as an HTML element on top of the SVG
24
+ * instead of an SVG text node. This allows using arbitrary HTML tags
25
+ * (e.g. links, styled spans) that cannot be embedded in SVG.
26
+ * The element will be displayed outside the box of the SVG element.
27
+ */
28
+ html?: boolean;
29
+ /**
30
+ * Maximum height of the title area. Accepts a pixel value (`number` or `"100px"`)
31
+ * or a percentage string (`"50%"`) relative to the full chart height.
32
+ * When the title content exceeds this height, it is clipped.
33
+ * Only applicable when `html: true`.
34
+ *
35
+ * Default: 50%
36
+ */
37
+ maxHeight?: string | number;
20
38
  }
@@ -1,5 +1,6 @@
1
1
  import type { TextRowData } from '../components/types';
2
- import type { ChartBrush, ChartData, ChartMargin, ChartTitle, ChartZoom, DeepRequired } from '../types';
2
+ import type { BaseTextStyle, ChartBrush, ChartData, ChartMargin, ChartTitle, ChartZoom, DeepRequired } from '../types';
3
+ import type { HtmlItem } from '../types/chart-ui';
3
4
  export type PreparedZoom = DeepRequired<Omit<ChartZoom, 'enabled' | 'brush'>> & DeepRequired<{
4
5
  brush: ChartBrush;
5
6
  }>;
@@ -7,10 +8,12 @@ export type PreparedChart = {
7
8
  margin: ChartMargin;
8
9
  zoom: PreparedZoom | null;
9
10
  };
10
- export type PreparedTitle = Omit<ChartTitle, 'margin'> & {
11
+ export type PreparedTitle = Omit<ChartTitle, 'margin' | 'style'> & {
11
12
  height: number;
12
13
  margin: number;
13
- contentRows: TextRowData[];
14
+ style: BaseTextStyle;
15
+ contentRows?: TextRowData[];
16
+ htmlElements?: HtmlItem[];
14
17
  };
15
18
  export type PreparedTooltip = ChartData['tooltip'] & {
16
19
  enabled: boolean;
@@ -19,8 +19,9 @@ export const HtmlLayer = (props) => {
19
19
  return null;
20
20
  }
21
21
  return (React.createElement(Portal, { container: htmlLayout }, items.map((item, index) => {
22
- var _a, _b, _c;
23
- const style = Object.assign(Object.assign({}, item.style), { color: (_b = (_a = item.style) === null || _a === void 0 ? void 0 : _a.color) !== null && _b !== void 0 ? _b : (_c = item.style) === null || _c === void 0 ? void 0 : _c.fontColor, position: 'absolute', left: item.x, top: item.y });
24
- return (React.createElement("div", { className: b('html-layer-item'), key: index, dangerouslySetInnerHTML: { __html: item.content }, style: style }));
22
+ var _a, _b, _c, _d;
23
+ const scope = (_a = item.scope) !== null && _a !== void 0 ? _a : 'plot';
24
+ const style = Object.assign(Object.assign({}, item.style), { color: (_c = (_b = item.style) === null || _b === void 0 ? void 0 : _b.color) !== null && _c !== void 0 ? _c : (_d = item.style) === null || _d === void 0 ? void 0 : _d.fontColor, position: 'absolute', left: item.x, top: item.y });
25
+ return (React.createElement("div", { className: b('html-layer-item', { plot: scope === 'plot' }), key: index, dangerouslySetInnerHTML: { __html: item.content }, style: style }));
25
26
  })));
26
27
  };
@@ -81,8 +81,8 @@ export const LineSeriesShapes = (args) => {
81
81
  function handleShapeHover(data) {
82
82
  hoveredDataRef.current = data;
83
83
  const selected = (data === null || data === void 0 ? void 0 : data.filter((d) => d.series.type === 'line')) || [];
84
+ const selectedDataItems = selected.map((d) => d.data);
84
85
  const selectedSeriesIds = selected.map((d) => { var _a; return (_a = d.series) === null || _a === void 0 ? void 0 : _a.id; });
85
- const closestChunk = selected.find((d) => d.closest);
86
86
  lineSelection.datum((d, index, list) => {
87
87
  const elementSelection = select(list[index]);
88
88
  const hovered = Boolean(hoverEnabled && selectedSeriesIds.includes(d.id));
@@ -118,7 +118,7 @@ export const LineSeriesShapes = (args) => {
118
118
  });
119
119
  markerSelection.datum((d, index, list) => {
120
120
  const elementSelection = select(list[index]);
121
- const hovered = Boolean(hoverEnabled && d.point.data === (closestChunk === null || closestChunk === void 0 ? void 0 : closestChunk.data));
121
+ const hovered = Boolean(hoverEnabled && selectedDataItems.includes(d.point.data));
122
122
  if (d.hovered !== hovered) {
123
123
  d.hovered = hovered;
124
124
  elementSelection.attr('visibility', getMarkerVisibility(d));
@@ -141,7 +141,7 @@ export const LineSeriesShapes = (args) => {
141
141
  hoverMarkersSvgElement.selectAll('*').remove();
142
142
  if (hoverEnabled && selected.length > 0) {
143
143
  const hoverOnlyMarkers = [];
144
- for (const chunk of selected.filter((c) => c.closest)) {
144
+ for (const chunk of selected) {
145
145
  const seriesData = preparedData.find((pd) => pd.id === chunk.series.id);
146
146
  if (!seriesData) {
147
147
  continue;
@@ -24,6 +24,8 @@ export interface HtmlItem {
24
24
  height: number;
25
25
  };
26
26
  style?: BaseTextStyle & React.CSSProperties;
27
+ /** Coordinate space for positioning: 'plot' uses the plot area origin, 'chart' uses the full chart origin. Defaults to 'plot'. */
28
+ scope?: 'plot' | 'chart';
27
29
  }
28
30
  export interface ShapeDataWithLabels {
29
31
  svgLabels: LabelData[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gravity-ui/charts",
3
- "version": "1.45.0",
3
+ "version": "1.46.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",