diff --git a/semcore/d3-chart/CHANGELOG.md b/semcore/d3-chart/CHANGELOG.md index 4e71318513..eb8926c85a 100644 --- a/semcore/d3-chart/CHANGELOG.md +++ b/semcore/d3-chart/CHANGELOG.md @@ -2,6 +2,16 @@ CHANGELOG.md standards are inspired by [keepachangelog.com](https://keepachangelog.com/en/1.0.0/). +## [16.3.0] - 2025-10-17 + +### Added + +- New `multiline` property for `XAxis.Ticks/YAxis.Ticks` and `multilineXTicks/multilineYTicks` for `Chart`. + +### Fixed + +- Chart content remains visible after unchecking single legend item in Bar, Horizontal Bar, Histogram, and Stacked Horizontal Bar charts. + ## [16.2.1] - 2025-10-17 ### Fixed diff --git a/semcore/d3-chart/__tests__/bar-chart.browser-test.tsx b/semcore/d3-chart/__tests__/bar-chart.browser-test.tsx index ff5df0665d..0324ba1ea1 100644 --- a/semcore/d3-chart/__tests__/bar-chart.browser-test.tsx +++ b/semcore/d3-chart/__tests__/bar-chart.browser-test.tsx @@ -297,4 +297,23 @@ test.describe('Bar chart', () => { await page.waitForTimeout(500); await expect(page).toHaveScreenshot(); }); + + test('Verify multiline tick labels', async ({ page }) => { + const standPath = 'stories/components/d3-chart/tests/examples/bar-chart/basic-usage.tsx'; + const htmlContent = await e2eStandToHtml(standPath, 'en', { + data: [ + { category: 'Google AI Mode 0 Top', bar: 2 }, + { category: 'Google AI Mode 1 Top', bar: 5 }, + { category: 'Google AI Mode 2 Top', bar: 7 }, + { category: 'Google AI Mode 3 Top', bar: 4 }, + { category: 'Google AI Mode 4 Top', bar: 8 }, + ], + multilineXTicks: true, + marginX: 60, + }); + + await page.setContent(htmlContent); + + await expect(page).toHaveScreenshot(); + }); }); diff --git a/semcore/d3-chart/__tests__/bar-chart.browser-test.tsx-snapshots/Bar-chart-Verify-multiline-tick-labels-1-chromium-linux.png b/semcore/d3-chart/__tests__/bar-chart.browser-test.tsx-snapshots/Bar-chart-Verify-multiline-tick-labels-1-chromium-linux.png new file mode 100644 index 0000000000..2856ef9494 Binary files /dev/null and b/semcore/d3-chart/__tests__/bar-chart.browser-test.tsx-snapshots/Bar-chart-Verify-multiline-tick-labels-1-chromium-linux.png differ diff --git a/semcore/d3-chart/__tests__/bar-chart.browser-test.tsx-snapshots/Bar-chart-Verify-multiline-tick-labels-1-firefox-linux.png b/semcore/d3-chart/__tests__/bar-chart.browser-test.tsx-snapshots/Bar-chart-Verify-multiline-tick-labels-1-firefox-linux.png new file mode 100644 index 0000000000..07b0c3c1e1 Binary files /dev/null and b/semcore/d3-chart/__tests__/bar-chart.browser-test.tsx-snapshots/Bar-chart-Verify-multiline-tick-labels-1-firefox-linux.png differ diff --git a/semcore/d3-chart/__tests__/bar-chart.browser-test.tsx-snapshots/Bar-chart-Verify-multiline-tick-labels-1-webkit-linux.png b/semcore/d3-chart/__tests__/bar-chart.browser-test.tsx-snapshots/Bar-chart-Verify-multiline-tick-labels-1-webkit-linux.png new file mode 100644 index 0000000000..ee65a5f6bb Binary files /dev/null and b/semcore/d3-chart/__tests__/bar-chart.browser-test.tsx-snapshots/Bar-chart-Verify-multiline-tick-labels-1-webkit-linux.png differ diff --git a/semcore/d3-chart/__tests__/bar-horizontal-chart.browser-test.tsx-snapshots/Horizontal-Bar-chart-Verify-Chart-Bar-renders-and-tooltip-shown-on-hover-1-chromium-linux.png b/semcore/d3-chart/__tests__/bar-horizontal-chart.browser-test.tsx-snapshots/Horizontal-Bar-chart-Verify-Chart-Bar-renders-and-tooltip-shown-on-hover-1-chromium-linux.png index af84d4dd89..b5e48fe2d6 100644 Binary files a/semcore/d3-chart/__tests__/bar-horizontal-chart.browser-test.tsx-snapshots/Horizontal-Bar-chart-Verify-Chart-Bar-renders-and-tooltip-shown-on-hover-1-chromium-linux.png and b/semcore/d3-chart/__tests__/bar-horizontal-chart.browser-test.tsx-snapshots/Horizontal-Bar-chart-Verify-Chart-Bar-renders-and-tooltip-shown-on-hover-1-chromium-linux.png differ diff --git a/semcore/d3-chart/__tests__/bar-horizontal-chart.browser-test.tsx-snapshots/Horizontal-Bar-chart-Verify-Chart-Bar-renders-and-tooltip-shown-on-hover-1-firefox-linux.png b/semcore/d3-chart/__tests__/bar-horizontal-chart.browser-test.tsx-snapshots/Horizontal-Bar-chart-Verify-Chart-Bar-renders-and-tooltip-shown-on-hover-1-firefox-linux.png index 3ebb905d80..08841e7334 100644 Binary files a/semcore/d3-chart/__tests__/bar-horizontal-chart.browser-test.tsx-snapshots/Horizontal-Bar-chart-Verify-Chart-Bar-renders-and-tooltip-shown-on-hover-1-firefox-linux.png and b/semcore/d3-chart/__tests__/bar-horizontal-chart.browser-test.tsx-snapshots/Horizontal-Bar-chart-Verify-Chart-Bar-renders-and-tooltip-shown-on-hover-1-firefox-linux.png differ diff --git a/semcore/d3-chart/__tests__/bar-horizontal-chart.browser-test.tsx-snapshots/Horizontal-Bar-chart-Verify-Chart-Bar-renders-and-tooltip-shown-on-hover-1-webkit-linux.png b/semcore/d3-chart/__tests__/bar-horizontal-chart.browser-test.tsx-snapshots/Horizontal-Bar-chart-Verify-Chart-Bar-renders-and-tooltip-shown-on-hover-1-webkit-linux.png index 03f211c3c4..2615abc9ad 100644 Binary files a/semcore/d3-chart/__tests__/bar-horizontal-chart.browser-test.tsx-snapshots/Horizontal-Bar-chart-Verify-Chart-Bar-renders-and-tooltip-shown-on-hover-1-webkit-linux.png and b/semcore/d3-chart/__tests__/bar-horizontal-chart.browser-test.tsx-snapshots/Horizontal-Bar-chart-Verify-Chart-Bar-renders-and-tooltip-shown-on-hover-1-webkit-linux.png differ diff --git a/semcore/d3-chart/src/Axis.jsx b/semcore/d3-chart/src/Axis.jsx index d829883822..192b8995f2 100644 --- a/semcore/d3-chart/src/Axis.jsx +++ b/semcore/d3-chart/src/Axis.jsx @@ -1,5 +1,5 @@ import { Component, sstyled } from '@semcore/core'; -import React from 'react'; +import React, { useState, useEffect } from 'react'; import createElement from './createElement'; import style from './style/axis.shadow.css'; @@ -186,6 +186,54 @@ function renderValue(value) { return value; } +function splitTextByWidth(root, text, maxWidth) { + if (!text || !maxWidth || maxWidth <= 0) return []; + + const words = text.split(/\s+/).filter((word) => word.length > 0); + if (words.length === 0) return []; + + const lines = []; + let currentLine = words[0]; + + for (let i = 1; i < words.length; i++) { + const testLine = `${currentLine} ${words[i]}`.trim(); + const testWidth = measureTextWidth(root, testLine); + + if (testWidth <= maxWidth) { + currentLine = testLine; + } else { + if (currentLine) { + lines.push(currentLine); + } + + currentLine = words[i]; + + if (measureTextWidth(root, currentLine) > maxWidth) { + lines.push(currentLine); + currentLine = ''; + } + } + } + + if (currentLine) { + lines.push(currentLine); + } + + return lines; +} + +function measureTextWidth(rootRef, text, fontSize = 12) { + const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + textEl.setAttribute('font-size', fontSize); + textEl.setAttribute('visibility', 'hidden'); + textEl.textContent = text; + + rootRef.appendChild(textEl); + const width = textEl.getComputedTextLength(); + rootRef.removeChild(textEl); + return width; +} + class AxisRoot extends Component { static displayName = 'Axis'; @@ -246,8 +294,31 @@ function Ticks(props) { dataHintsHandler, children, childrenPosition = 'inside', + rootRef, + multiline, } = props; + const [ticksState, setTicksState] = useState([]); + + useEffect(() => { + const tickBandwidth = scale[indexScale]?.bandwidth?.(); + + const ticksWithLines = ticks.map((tick) => { + let lines = []; + + if (typeof tick === 'string' && multiline) { + lines = splitTextByWidth(rootRef.current, tick, tickBandwidth); + } + + return { + tick, + lines, + }; + }); + + setTicksState(ticksWithLines); + }, [ticks, multiline]); + const pos = MAP_POSITION_TICK[position] ?? MAP_POSITION_TICK[MAP_INDEX_SCALE_SYMBOL[indexScale]]; const positionClass = MAP_POSITION_TICK[position] ? position : `custom_${indexScale}`; @@ -263,7 +334,7 @@ function Ticks(props) { } } - return ticks.map((value, i) => { + return ticksState.map(({ tick: value, lines }, i) => { const displayValue = typeof children === 'function' ? undefined : renderValue(value); return sstyled(styles)( @@ -277,9 +348,14 @@ function Ticks(props) { index={i} position={positionClass} hide={hide} + multiline={multiline} {...pos(scale, value, position)} > - {displayValue} + { lines.length > 1 + ? lines.map((line, lineIndex) => ( + {line} + )) + : displayValue} , ); }); diff --git a/semcore/d3-chart/src/component/Chart/AbstractChart.tsx b/semcore/d3-chart/src/component/Chart/AbstractChart.tsx index 3813c0beea..bace577f42 100644 --- a/semcore/d3-chart/src/component/Chart/AbstractChart.tsx +++ b/semcore/d3-chart/src/component/Chart/AbstractChart.tsx @@ -401,8 +401,16 @@ export abstract class AbstractChart< } protected renderAxis(): React.ReactNode { - const { invertAxis, showXAxis, showYAxis, data, axisXValueFormatter, axisYValueFormatter } = - this.asProps; + const { + invertAxis, + showXAxis, + showYAxis, + data, + axisXValueFormatter, + axisYValueFormatter, + multilineXTicks, + multilineYTicks, + } = this.asProps; if (!Array.isArray(data)) { return null; @@ -424,10 +432,10 @@ export abstract class AbstractChart< {yTicks ? ( - {childrenY} + {childrenY} ) : ( - {childrenY} + {childrenY} )} {invertAxis !== true && (yTicks ? : )} @@ -437,10 +445,10 @@ export abstract class AbstractChart< {xTicks ? ( - {childrenX} + {childrenX} ) : ( - {childrenX} + {childrenX} )} {invertAxis === true && (xTicks ? : )} diff --git a/semcore/d3-chart/src/component/Chart/AbstractChart.type.ts b/semcore/d3-chart/src/component/Chart/AbstractChart.type.ts index a4971ac17a..dc4606fdbb 100644 --- a/semcore/d3-chart/src/component/Chart/AbstractChart.type.ts +++ b/semcore/d3-chart/src/component/Chart/AbstractChart.type.ts @@ -124,6 +124,10 @@ export type BaseChartProps = FlexProps & * Count of ticks for Y axis */ yTicksCount?: number; + /** Enables multiline tick labels for X axis, applicable only for band scales */ + multilineXTicks?: boolean; + /** Enables multiline tick labels for Y axis, applicable only for band scales */ + multilineYTicks?: boolean; /** * Group key for all array-based charts (for get keys of items for legend except that group key) */ diff --git a/semcore/d3-chart/src/style/axis.shadow.css b/semcore/d3-chart/src/style/axis.shadow.css index 83269a9452..8b6729dc9c 100644 --- a/semcore/d3-chart/src/style/axis.shadow.css +++ b/semcore/d3-chart/src/style/axis.shadow.css @@ -61,28 +61,64 @@ STick[position='bottom'] { transform: translateY(12px); text-anchor: middle; dominant-baseline: hanging; + + tspan { + dominant-baseline: hanging; + } } STick[position='right'] { transform: translateX(16px); text-anchor: start; dominant-baseline: middle; + + tspan { + dominant-baseline: middle; + } + + &[multiline] { + dominant-baseline: auto; + + tspan { + dominant-baseline: auto; + } + } } STick[position='left'] { transform: translateX(-16px); text-anchor: end; dominant-baseline: middle; + + tspan { + dominant-baseline: middle; + } + + &[multiline] { + dominant-baseline: auto; + + tspan { + dominant-baseline: auto; + } + } } STick[position='custom_0'] { transform: translateY(12px); text-anchor: middle; dominant-baseline: hanging; + + tspan { + dominant-baseline: hanging; + } } STick[position='custom_1'] { transform: translateX(-16px); text-anchor: end; dominant-baseline: middle; + + tspan { + dominant-baseline: middle; + } } diff --git a/semcore/d3-chart/src/types/Axis.d.ts b/semcore/d3-chart/src/types/Axis.d.ts index 2be5914ef0..f25bf81597 100644 --- a/semcore/d3-chart/src/types/Axis.d.ts +++ b/semcore/d3-chart/src/types/Axis.d.ts @@ -38,6 +38,8 @@ export type AxisTicksProps = Context & { hide?: boolean; /** Values for axis ticks */ ticks?: any[]; + /** Enables multiline tick labels, applicable only for band scales */ + multiline?: boolean; }; /** @deprecated */ diff --git a/stories/components/d3-chart/tests/bar-chart.stories.tsx b/stories/components/d3-chart/tests/bar-chart.stories.tsx index e25ab7eb46..0253cf9f44 100644 --- a/stories/components/d3-chart/tests/bar-chart.stories.tsx +++ b/stories/components/d3-chart/tests/bar-chart.stories.tsx @@ -36,6 +36,20 @@ export const BasicUsage: StoryObj = { control: 'select', options: [true, false, undefined], }, + multilineXTicks: { + control: 'select', + options: [true, false, undefined], + }, + marginX: { + control: 'number', + }, + multilineYTicks: { + control: 'select', + options: [true, false, undefined], + }, + marginY: { + control: 'number', + }, }, args: BasicUsageProps, }; diff --git a/stories/components/d3-chart/tests/d3-chart-base.stories.tsx b/stories/components/d3-chart/tests/d3-chart-base.stories.tsx index 6f204af4b2..76934e4b6c 100644 --- a/stories/components/d3-chart/tests/d3-chart-base.stories.tsx +++ b/stories/components/d3-chart/tests/d3-chart-base.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import AdaptivePropsExample from './examples/d3-chart/adaptive-props'; -import GridAxisPropsExample from './examples/d3-chart/grid-axis-props'; +import GridAxisPropsExample, { defaultProps as BasicUsageProps } from './examples/d3-chart/grid-axis-props'; import PlotAndA11yPropsExample from './examples/d3-chart/plot-props'; import ReferenceLinePropsExample from './examples/d3-chart/reference-line-props'; import TooltipHoverExample from './examples/d3-chart/tooltip-and-hover-line'; @@ -16,8 +16,15 @@ export const PlotAndA11yProps: StoryObj = { render: PlotAndA11yPropsExample, }; -export const GridAxisProps: StoryObj = { +export const GridAxisProps: StoryObj = { render: GridAxisPropsExample, + argTypes: { + multiline: { + control: 'select', + options: [true, false, undefined], + }, + }, + args: BasicUsageProps, }; export const ReferenceLineProps: StoryObj = { diff --git a/stories/components/d3-chart/tests/examples/bar-chart/basic-usage.tsx b/stories/components/d3-chart/tests/examples/bar-chart/basic-usage.tsx index 805aaa027a..c0b27ef635 100644 --- a/stories/components/d3-chart/tests/examples/bar-chart/basic-usage.tsx +++ b/stories/components/d3-chart/tests/examples/bar-chart/basic-usage.tsx @@ -1,27 +1,40 @@ import { Chart } from '@semcore/ui/d3-chart'; import React from 'react'; + type BaseExampleProps = { showLegend?: boolean; + multilineXTicks?: boolean; + marginX?: number; + multilineYTicks?: boolean; + marginY?: number; + data?: Array<{ + category: string; + bar: number; + }>; }; + const Demo = (props: BaseExampleProps) => { - const { showLegend } = props; + const { showLegend, multilineXTicks, data, marginX, multilineYTicks, marginY } = props; return ( <> { /* @ts-ignore: the value is not statically known, but it's valid at runtime */} - ); }; -const data = [ +const defaultData = [ { category: 'Category 0', bar: 2 }, { category: 'Category 1', bar: 5 }, { category: 'Category 2', bar: 7 }, @@ -31,7 +44,10 @@ const data = [ export const defaultProps: BaseExampleProps = { showLegend: undefined, - + multilineXTicks: undefined, + marginX: undefined, + multilineYTicks: undefined, + marginY: undefined, }; export default Demo; diff --git a/stories/components/d3-chart/tests/examples/bar-horizontal/basic-usage.tsx b/stories/components/d3-chart/tests/examples/bar-horizontal/basic-usage.tsx index df23ab0e33..9932c166cc 100644 --- a/stories/components/d3-chart/tests/examples/bar-horizontal/basic-usage.tsx +++ b/stories/components/d3-chart/tests/examples/bar-horizontal/basic-usage.tsx @@ -17,6 +17,8 @@ const Demo = (props: BaseExampleProps) => { invertAxis={true} aria-label='CompactHorizontalBar chart' showLegend={showLegend} + multilineYTicks={true} + marginY={100} /> ); diff --git a/stories/components/d3-chart/tests/examples/d3-chart/grid-axis-props.tsx b/stories/components/d3-chart/tests/examples/d3-chart/grid-axis-props.tsx index ce018b9082..29b5d2b7c3 100644 --- a/stories/components/d3-chart/tests/examples/d3-chart/grid-axis-props.tsx +++ b/stories/components/d3-chart/tests/examples/d3-chart/grid-axis-props.tsx @@ -3,7 +3,11 @@ import { Flex } from '@semcore/ui/flex-box'; import { scaleLinear, scaleBand } from 'd3-scale'; import React from 'react'; -const Demo = () => { +type BaseExampleProps = { + multiline?: boolean; +}; + +const Demo = (props: BaseExampleProps) => { const MARGIN = 40; const width = 250; const height = 200; @@ -37,17 +41,29 @@ const Demo = () => { return ( - + - + - + @@ -57,7 +73,12 @@ const Demo = () => { - + @@ -71,7 +92,12 @@ const Demo = () => { - + @@ -88,40 +114,73 @@ const Demo = () => { - + - + YAxis title - + XAxis title - + - + YAxis title - + XAxis title - + YAxis title - + XAxis title @@ -129,7 +188,12 @@ const Demo = () => { - + @@ -142,7 +206,12 @@ const Demo = () => { - + @@ -154,7 +223,12 @@ const Demo = () => { - + @@ -179,8 +253,13 @@ const data = Array(20) const data2 = Array(5) .fill({}) .map((d, i) => ({ - category: `Cat ${i}`, + category: `Cat Cat Cat ${i}`, bar: Math.sin(i / 2) * 5 + 5, })); +export const defaultProps: BaseExampleProps = { + + multiline: undefined, +}; + export default Demo;