diff --git a/playground/entries/Chart/ScoreDonut.tsx b/playground/entries/Chart/ScoreDonut.tsx new file mode 100644 index 0000000000..9ef5daca2c --- /dev/null +++ b/playground/entries/Chart/ScoreDonut.tsx @@ -0,0 +1,95 @@ +import { Chart } from '@semcore/ui/d3-chart'; +import { Text } from '@semcore/ui/typography'; +import React from 'react'; + +import { getDefaultChartControls } from './common/controls'; +import type { CommonChartProps, LegendChartProps } from './common/controls'; +import type { JSXProps } from '../../types/JSXProps'; +import type { PlaygroundEntry } from '../../types/Playground'; +import createGithubLink from '../../utils/createGHLink'; + +type DonutChartProps = { + commonChartProps: Omit; + legendProps: Omit; + donutProps: { + halfsize: boolean; + innerLabel: string; + }; +}; +export type DonutChartJSXProps = JSXProps; + +const data = { + value: 33, +}; + +function getJSX(props: DonutChartJSXProps) { + const legendProps = { + ...props.legendProps, + }; + return ( + + {props.donutProps.innerLabel} + + ) + : props.donutProps.innerLabel + )} + {...props.commonChartProps} + {...(props.legendProps && { + legendProps, + showLegend: props.commonChartProps.showLegend as true, + })} + {...(props.legendProps?.patterns && { patterns: props.legendProps.patterns })} + /> + ); +} + +const entry: PlaygroundEntry = { + JSX: (props) => getJSX(props), + controls: { + donutProps: { + type: 'group', + groupName: 'Donut props', + controls: { + halfsize: { + type: 'boolean', + value: false, + displayName: 'Half size', + }, + innerLabel: { + type: 'text', + value: 'Example', + displayName: 'Inner Label', + }, + }, + }, + ...getDefaultChartControls({ + skip: { + commonChartProps: ['showTotalInTooltip'], + legendProps: ['direction'], + }, + add: { + legendProps: { + shape: { + type: 'select', + value: 'Circle', + options: ['Checkbox', 'Circle', 'Pattern'], + displayName: 'Shape', + }, + }, + }, + }), + }, + link: createGithubLink('d3-chart'), + filterProps: ['data'], +}; + +export default entry; diff --git a/playground/registry.ts b/playground/registry.ts index 502edfcc70..e52784d6b0 100644 --- a/playground/registry.ts +++ b/playground/registry.ts @@ -13,6 +13,7 @@ import ChartLine from './entries/Chart/Line'; import MiniChart from './entries/Chart/MiniChart'; import ChartRadar from './entries/Chart/Radar'; import ChartScatterPlot from './entries/Chart/ScatterPlot'; +import ChartScoreDonut from './entries/Chart/ScoreDonut'; import ChartStackedArea from './entries/Chart/StackedArea'; import ChartStackedBar from './entries/Chart/StackedBar'; import ChartStackedHorizontalBar from './entries/Chart/StackedHorizontalBar'; @@ -89,6 +90,7 @@ const registry = { 'Chart.Cigarette': ChartCigarette, 'ChartLegend': ChartLegend, 'Chart.Donut': ChartDonut, + 'Chart.ScoreDonut': ChartScoreDonut, 'Chart.Histogram': ChartHistogram, 'Chart.Line': ChartLine, 'MiniChart': MiniChart, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffb728d3bb..9bb5dde3b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -712,9 +712,6 @@ importers: d3-array: specifier: 3.1.6 version: 3.1.6 - d3-ease: - specifier: 3.0.1 - version: 3.0.1 d3-interpolate: specifier: 3.0.1 version: 3.0.1 @@ -19656,7 +19653,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(happy-dom@9.20.3)(jsdom@22.1.0)(lightningcss@1.31.1)(sass@1.100.0)(sugarss@5.0.1(postcss@8.5.15))(terser@5.48.0)(yaml@2.8.3) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(happy-dom@9.20.3)(jsdom@22.1.0)(lightningcss@1.31.1)(sass@1.100.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.48.0)(yaml@2.8.3) '@vitest/utils@3.2.4': dependencies: diff --git a/semcore/d3-chart/__tests__/axe-tests/score-donut-chart.axe-test.tsx b/semcore/d3-chart/__tests__/axe-tests/score-donut-chart.axe-test.tsx new file mode 100644 index 0000000000..7bf3d223fd --- /dev/null +++ b/semcore/d3-chart/__tests__/axe-tests/score-donut-chart.axe-test.tsx @@ -0,0 +1,17 @@ +import { expect, test, getAccessibilityViolations } from '@semcore/testing-utils/playwright'; +import { loadPage } from '@semcore/testing-utils/shared/helpers'; +import { TAG } from '@semcore/testing-utils/shared/tags'; + +test.describe(`@d3-chart @score-donut-chart ${TAG.ACCESSIBILITY}`, () => { + test('basic-usage', async ({ page }) => { + await loadPage(page, 'stories/components/d3-chart/docs/examples/score-donut-chart/basic-usage.tsx', 'en'); + const violations = await getAccessibilityViolations({ page }); + expect(violations).toEqual([]); + }); + + test('semi-donut', async ({ page }) => { + await loadPage(page, 'stories/components/d3-chart/docs/examples/score-donut-chart/semi-donut.tsx', 'en'); + const violations = await getAccessibilityViolations({ page }); + expect(violations).toEqual([]); + }); +}); diff --git a/semcore/d3-chart/__tests__/index.test.tsx b/semcore/d3-chart/__tests__/index.test.tsx index e13e971c52..42ba2f3baf 100644 --- a/semcore/d3-chart/__tests__/index.test.tsx +++ b/semcore/d3-chart/__tests__/index.test.tsx @@ -849,6 +849,295 @@ describe('Donut geometry', () => { }); }); +const SCORE_DONUT_PLOT = { plotWidth: 300, plotHeight: 300, duration: 0 } as const; + +const renderScoreDonut = (props: Record = {}) => + render( + , + ); + +const scoreDonutPies = (container: HTMLElement) => queryAllByAttribute('data-ui-name', container, 'Donut.Pie'); + +/** Angle (in degrees, clockwise from 12 o'clock) swept by the first arc command of a pie path. */ +const pieSweepDegrees = (pie: Element) => { + const d = pie.getAttribute('d') ?? ''; + const start = d.match(/^M([-\d.e]+),([-\d.e]+)/); + const end = d.match(/A[\d.]+,[\d.]+,0,(\d),1,([-\d.e]+),([-\d.e]+)/); + + if (!start || !end) return Number.NaN; + + const angle = (x: number, y: number) => ((Math.atan2(x, -y) * 180) / Math.PI + 360) % 360; + const startAngle = angle(Number(start[1]), Number(start[2])); + let sweep = angle(Number(end[2]), Number(end[3])) - startAngle; + + if (sweep <= 0) sweep += 360; + + return sweep; +}; + +describe('Chart.ScoreDonut', () => { + beforeEach(cleanup); + + describe('data.value equivalence classes', () => { + test('should split a valid value into value and other pies', async () => { + const { container } = renderScoreDonut({ data: { value: 40 } }); + const [valuePie, otherPie] = scoreDonutPies(container); + + expect(scoreDonutPies(container)).toHaveLength(2); + expect(pieSweepDegrees(valuePie)).toBeCloseTo(144, 0); + expect(pieSweepDegrees(otherPie)).toBeCloseTo(216, 0); + expect(otherPie.getAttribute('mask')).toMatch(/^url\(#.+-mask\)$/); + expect(valuePie.getAttribute('mask')).toBeNull(); + }); + + test('should keep a fractional value proportional', async () => { + const { container } = renderScoreDonut({ data: { value: 33.33 } }); + const [valuePie] = scoreDonutPies(container); + + expect(pieSweepDegrees(valuePie)).toBeCloseTo(120, 0); + }); + + test('should ignore extra keys in data', async () => { + const { container, getAllByText } = renderScoreDonut({ data: { value: 40, test: 60 }, showLegend: true }); + + expect(scoreDonutPies(container)).toHaveLength(2); + expect(getAllByText('value')).toHaveLength(1); + expect(container.textContent).not.toContain('test'); + }); + }); + + describe('data.value boundary values', () => { + test('should render only the other pie for value 0', async () => { + const { container, getByText } = renderScoreDonut({ data: { value: 0 }, showLegend: true }); + const pies = scoreDonutPies(container); + + expect(pies).toHaveLength(1); + expect(pies[0].getAttribute('mask')).not.toBeNull(); + expect(getByText('0.00%')).toBeTruthy(); + }); + + test('should render only the value pie for value 100', async () => { + const { container } = renderScoreDonut({ data: { value: 100 } }); + const pies = scoreDonutPies(container); + + expect(pies).toHaveLength(1); + expect(pies[0].getAttribute('mask')).toBeNull(); + }); + + test('should split value 50 into two equal halves', async () => { + const { container } = renderScoreDonut({ data: { value: 50 } }); + const [valuePie, otherPie] = scoreDonutPies(container); + + expect(pieSweepDegrees(valuePie)).toBeCloseTo(180, 0); + expect(pieSweepDegrees(otherPie)).toBeCloseTo(180, 0); + }); + + test.each([0.01, 0.99, 1, 1.01])( + 'should keep a tiny value %s visible as about a 1%% slice', + async (value) => { + const { container } = renderScoreDonut({ data: { value } }); + const [valuePie] = scoreDonutPies(container); + + // Donut enlarges a slice below 1% of the total to 1% of it, i.e. ~0.99% of the new total. + expect(scoreDonutPies(container)).toHaveLength(2); + expect(pieSweepDegrees(valuePie)).toBeGreaterThan(3.5); + }, + ); + + test.each([99, 99.99])('should keep a tiny other slice visible for value %s', async (value) => { + const { container } = renderScoreDonut({ data: { value } }); + const [, otherPie] = scoreDonutPies(container); + + expect(scoreDonutPies(container)).toHaveLength(2); + expect(pieSweepDegrees(otherPie)).toBeGreaterThan(3.5); + }); + }); + + describe('innerRadius boundary values', () => { + test('should render a 16px ring by default', async () => { + const { container } = renderScoreDonut(); + const [pie] = scoreDonutPies(container); + + expect(getPieGeometry(pie).thickness).toBe(DONUT_THICKNESS); + }); + + test('should render a full pie for innerRadius 0', async () => { + const { container } = renderScoreDonut({ innerRadius: 0 }); + const [pie] = scoreDonutPies(container); + + expect(getPieGeometry(pie).innerRadius).toBe(0); + }); + + test('should render a 1px ring for innerRadius = outerRadius - 1', async () => { + const { container } = renderScoreDonut({ innerRadius: 145 }); + const [pie] = scoreDonutPies(container); + + expect(getPieGeometry(pie).thickness).toBe(1); + }); + + test('should not crash for innerRadius >= outerRadius', async () => { + expect(() => renderScoreDonut({ innerRadius: 200 })).not.toThrow(); + }); + }); + + describe('legend visibility (decision table)', () => { + test.each([ + ['undefined', undefined, false], + ['true', true, true], + ['false', false, false], + ])('showLegend %s', async (_, showLegend, expectedVisible) => { + const { queryByLabelText, container } = renderScoreDonut( + showLegend === undefined ? {} : { showLegend }, + ); + + expect(Boolean(queryByLabelText('Chart legend'))).toBe(expectedVisible); + expect(scoreDonutPies(container)).toHaveLength(2); + }); + }); + + describe('legend item content (decision table)', () => { + const renderWithLegend = (legendMapValue?: Record) => + renderScoreDonut({ + showLegend: true, + legendProps: { legendType: 'Table', ...(legendMapValue ? { legendMap: { value: legendMapValue } } : {}) }, + }); + + test('should compute percent and value columns without legendMap', async () => { + const { getByText } = renderWithLegend(); + + expect(getByText('value')).toBeTruthy(); + expect(getByText('40.00%')).toBeTruthy(); + expect(getByText('40')).toBeTruthy(); + }); + + test('should use a custom label and keep the computed columns', async () => { + const { getByText, queryByText } = renderWithLegend({ label: 'SEO score' }); + + expect(getByText('SEO score')).toBeTruthy(); + expect(queryByText('value')).toBeNull(); + expect(getByText('40.00%')).toBeTruthy(); + }); + + test('should render count after the label', async () => { + const { getByText } = renderWithLegend({ count: 12 }); + + expect(getByText('(12)')).toBeTruthy(); + }); + + test('should prefer additionalInfo over count', async () => { + const { getByText, queryByText } = renderWithLegend({ additionalInfo: 'of 100', count: 12 }); + + expect(getByText('of 100')).toBeTruthy(); + expect(queryByText('(12)')).toBeNull(); + }); + + test('should render given rows instead of computed columns', async () => { + const { getByText, queryByText } = renderWithLegend({ rows: [custom row] }); + + expect(getByText('custom row')).toBeTruthy(); + expect(queryByText('40.00%')).toBeNull(); + }); + + test('should render given columns instead of computed columns', async () => { + const { getByText, queryByText } = renderWithLegend({ columns: [custom column] }); + + expect(getByText('custom column')).toBeTruthy(); + expect(queryByText('40.00%')).toBeNull(); + }); + + test.each([['rows'], ['columns']])('should accept an empty %s array', async (key) => { + expect(() => renderWithLegend({ [key]: [] })).not.toThrow(); + }); + + test('should start unchecked with defaultChecked false and not draw the value pie', async () => { + const { container } = renderWithLegend({ defaultChecked: false }); + const masked = scoreDonutPies(container).filter((pie) => pie.getAttribute('mask')); + + expect(scoreDonutPies(container)).toHaveLength(masked.length); + }); + }); + + describe('state transitions', () => { + test('should update pies when value changes 40 -> 80 -> 0 -> 40', async () => { + const { container, rerender } = renderScoreDonut({ data: { value: 40 } }); + + const rerenderWith = (value: number) => + rerender(); + + rerenderWith(80); + expect(pieSweepDegrees(scoreDonutPies(container)[0])).toBeCloseTo(288, 0); + + rerenderWith(0); + expect(scoreDonutPies(container)).toHaveLength(1); + + rerenderWith(40); + expect(scoreDonutPies(container)).toHaveLength(2); + expect(pieSweepDegrees(scoreDonutPies(container)[0])).toBeCloseTo(144, 0); + }); + + // B3: the other pie is drawn even when the value item is unchecked. + test.fails('should draw only the empty state when the value item is unchecked', async () => { + const { container, getByRole } = renderScoreDonut({ showLegend: true }); + + await userEvent.click(getByRole('checkbox')); + + expect(queryByAttribute('data-ui-name', container, 'Donut.EmptyData')).not.toBeNull(); + expect(scoreDonutPies(container)).toHaveLength(0); + }); + }); + + describe('error guessing', () => { + test('should call onClickPie with value and other keys', async () => { + const onClickPie = vi.fn(); + const { container } = renderScoreDonut({ onClickPie }); + const [valuePie, otherPie] = scoreDonutPies(container); + + await userEvent.click(valuePie); + await userEvent.click(otherPie); + + expect(onClickPie.mock.calls.map(([key]) => key)).toEqual(['value', 'other']); + }); + + test('should give two charts on one page unique mask ids', async () => { + const { container } = render( + <> + + + , + ); + const maskIds = [...container.querySelectorAll('mask')].map((mask) => mask.id); + const references = scoreDonutPies(container) + .map((pie) => pie.getAttribute('mask')) + .filter(Boolean); + + expect(maskIds).toHaveLength(2); + expect(new Set(maskIds).size).toBe(2); + references.forEach((reference, index) => expect(reference).toBe(`url(#${maskIds[index]})`)); + }); + + test('should render innerLabel inside the donut', async () => { + const { container } = renderScoreDonut({ innerLabel: '40%' }); + + expect(queryByAttribute('data-ui-name', container, 'Donut.Label')?.textContent).toBe('40%'); + }); + }); + + describe('accessibility', () => { + test('should put role group on the root and aria-label on the plot', async () => { + const { container } = renderScoreDonut(); + + expect(container.querySelector('[role="group"]')).not.toBeNull(); + expect(container.querySelector('svg[aria-label="Score donut chart"]')).not.toBeNull(); + }); + + test('should hide pies from assistive technologies', async () => { + const { container } = renderScoreDonut(); + + scoreDonutPies(container).forEach((pie) => expect(pie.closest('[aria-hidden]')).not.toBeNull()); + }); + }); +}); + describe('Chart.Line', () => { beforeEach(cleanup); diff --git a/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx new file mode 100644 index 0000000000..fa4023a6bd --- /dev/null +++ b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx @@ -0,0 +1,408 @@ +import type { Locator, Page } from '@semcore/testing-utils/playwright'; +import { expect, test } from '@semcore/testing-utils/playwright'; +import { loadPage } from '@semcore/testing-utils/shared/helpers'; +import { TAG } from '@semcore/testing-utils/shared/tags'; + +const STORY = 'stories/components/d3-chart/tests/examples/score-donut-chart/basic-usage.tsx'; + +const COMPONENT_TAGS = ['@score-donut-chart', '@d3-chart', '@base-components', '@flex-box', '@typography']; + +export const locators = { + plot: (page: Page) => page.locator('svg[data-ui-name="Plot"]'), + pie: (page: Page, index?: number) => { + const base = page.locator('[data-ui-name="Donut.Pie"]'); + return typeof index === 'number' ? base.nth(index) : base; + }, + otherPie: (page: Page) => page.locator('[data-ui-name="Donut.Pie"][mask]'), + valuePie: (page: Page) => page.locator('[data-ui-name="Donut.Pie"]:not([mask])'), + emptyData: (page: Page) => page.locator('[data-ui-name="Donut.EmptyData"]'), + tooltip: (page: Page) => page.locator('[data-ui-name="Donut.Tooltip"]'), + legend: (page: Page) => page.getByLabel('Chart legend'), + legendCheckbox: (page: Page, name = 'value') => page.getByLabel('Chart legend').getByRole('checkbox', { name }), + legendItem: (page: Page, text = 'value') => page.getByLabel('Chart legend').getByText(text, { exact: true }), +}; + +const load = async (page: Page, props: Record = {}) => { + await loadPage(page, STORY, 'en', { duration: 0, ...props }); + await locators.plot(page).waitFor({ state: 'visible' }); + // The chart measures its container first and re-renders with the final radius. + await page.waitForTimeout(300); +}; + +/** +Hovers the middle of the ring at a point of the outer arc. The midpoint between the outer and +inner arcs used by the Donut tests falls into the hole for wide arcs, so the point is taken in +the middle of the outer arc (a quarter of the path, which starts with the outer arc) and moved +halfway into the ring. The mouse is moved directly: +hovering starts the radius animation, and locator.hover() never sees the pie stable. +*/ +const hoverPie = async (pie: Locator) => { + const point = await pie.evaluate((path: SVGPathElement) => { + const onOuterArc = path.getPointAtLength(path.getTotalLength() * 0.25); + const radius = Math.hypot(onOuterArc.x, onOuterArc.y); + const scale = (radius - 8) / radius; + const svgPoint = path.ownerSVGElement!.createSVGPoint(); + + svgPoint.x = onOuterArc.x * scale; + svgPoint.y = onOuterArc.y * scale; + + const { x, y } = svgPoint.matrixTransform(path.getScreenCTM()!); + + return { x, y }; + }); + + await pie.page().mouse.move(point.x, point.y, { steps: 5 }); +}; + +/** Hovers a pie until its tooltip is shown: the first mouse events can land before the chart settles. */ +const hoverPieForTooltip = async (page: Page, pie: Locator) => { + await expect(async () => { + await page.mouse.move(0, 0); + await hoverPie(pie); + await expect(locators.tooltip(page)).toBeVisible({ timeout: 1000 }); + }).toPass(); +}; + +const outerRadius = (pie: Locator) => + pie.evaluate((path) => { + const radii = [...(path.getAttribute('d') ?? '').matchAll(/A([\d.]+),/g)].map((match) => Number(match[1])); + + return Math.max(...radii); + }); + +/** +Resolves the mask rectangle of the `other` pie into the pie's user space, where percentages +are taken from the svg viewport, and compares it with the pie's bounding box. +*/ +const maskCoversOtherPie = (page: Page) => + locators.otherPie(page).evaluate((path: SVGPathElement) => { + const svg = path.ownerSVGElement!; + const id = (path.getAttribute('mask') ?? '').replace(/^url\(#(.+)\)$/, '$1'); + const rect = svg.querySelector(`mask[id="${id}"] rect`)!; + const { width, height } = svg.getBoundingClientRect(); + const resolve = (value: string | null, base: number) => + value?.endsWith('%') ? (Number.parseFloat(value) / 100) * base : Number(value ?? 0); + + const maskX = resolve(rect.getAttribute('x'), width); + const maskY = resolve(rect.getAttribute('y'), height); + const maskWidth = resolve(rect.getAttribute('width'), width); + const maskHeight = resolve(rect.getAttribute('height'), height); + const box = path.getBBox(); + + return ( + box.x >= maskX && + box.y >= maskY && + box.x + box.width <= maskX + maskWidth && + box.y + box.height <= maskY + maskHeight + ); + }); + +/* ===================================================== +@visual +Visual states, hover styles and snapshots. +===================================================== */ +test.describe(`${TAG.VISUAL}`, () => { + const values = [ + { description: 'value 0: only the other pie', data: { value: 0 } }, + { description: 'value 50: two equal halves', data: { value: 50 } }, + { description: 'value 100: only the value pie', data: { value: 100 } }, + { description: 'innerRadius 0: full pie', data: { value: 40 }, innerRadius: 0 }, + ]; + + values.forEach((item) => { + test(`Verify score donut ${item.description}`, { + tag: [TAG.PRIORITY_HIGH, ...COMPONENT_TAGS], + }, async ({ page }) => { + const { description: _, ...props } = item; + await load(page, props); + + await test.step('Verify chart renders correctly', async () => { + await expect(page).toHaveScreenshot(); + }); + }); + }); + + // Pairwise coverage of halfsize × innerLabel × patterns × legendType × showTooltip × locale. + const pairwise = [ + { halfsize: false, withInnerLabel: false, patterns: false, legendType: 'Table', showTooltip: true, locale: 'en' }, + { halfsize: false, withInnerLabel: true, patterns: true, legendType: 'Flex', showTooltip: false, locale: 'ja' }, + { halfsize: true, withInnerLabel: false, patterns: true, legendType: 'Table', showTooltip: false, locale: 'ja' }, + { halfsize: true, withInnerLabel: true, patterns: false, legendType: 'Flex', showTooltip: true, locale: 'en' }, + { halfsize: false, withInnerLabel: false, patterns: true, legendType: 'Flex', showTooltip: true, locale: 'en' }, + { halfsize: true, withInnerLabel: true, patterns: false, legendType: 'Table', showTooltip: false, locale: 'ja' }, + { halfsize: false, withInnerLabel: true, patterns: false, legendType: 'Table', showTooltip: true, locale: 'ja' }, + { halfsize: true, withInnerLabel: false, patterns: true, legendType: 'Flex', showTooltip: true, locale: 'en' }, + ] as const; + + pairwise.forEach((item, index) => { + test(`Verify score donut pairwise combination ${index + 1}`, { + tag: [TAG.PRIORITY_MEDIUM, ...COMPONENT_TAGS, '@chart-legend'], + }, async ({ page }) => { + const { legendType, ...props } = item; + await load(page, { ...props, 'showLegend': true, 'legendProps.legendType': legendType }); + + await test.step('Verify chart renders correctly', async () => { + await expect(page).toHaveScreenshot(); + }); + + if (item.showTooltip) { + await test.step('Verify value pie highlights on hover', async () => { + await hoverPieForTooltip(page, locators.valuePie(page)); + await expect(page).toHaveScreenshot(); + }); + } + }); + }); +}); + +/* ===================================================== +@functional +Keyboard and mouse interactions - no snapshots here. +We verify states, visibility, and attributes. +===================================================== */ +test.describe(`${TAG.FUNCTIONAL}`, () => { + test.describe('Tooltip', () => { + test('Verify tooltip shows the value on the value pie', { + tag: [TAG.PRIORITY_HIGH, TAG.MOUSE, ...COMPONENT_TAGS], + }, async ({ page }) => { + await load(page, { data: { value: 40 } }); + + await test.step('Verify tooltip appears on hover', async () => { + await hoverPieForTooltip(page, locators.valuePie(page)); + await expect(locators.tooltip(page)).toContainText('value'); + await expect(locators.tooltip(page)).toContainText('40'); + }); + + await test.step('Verify tooltip disappears on leave', async () => { + await page.mouse.move(0, 0); + await expect(locators.tooltip(page)).toBeHidden(); + }); + }); + + test('Verify tooltip uses a custom value formatter', { + tag: [TAG.PRIORITY_MEDIUM, TAG.MOUSE, ...COMPONENT_TAGS], + }, async ({ page }) => { + await load(page, { data: { value: 40 }, useCustomValueFormatter: true }); + + await hoverPieForTooltip(page, locators.valuePie(page)); + await expect(locators.tooltip(page)).toContainText('40% score'); + }); + + test('Verify no tooltip when showTooltip is false', { + tag: [TAG.PRIORITY_MEDIUM, TAG.MOUSE, ...COMPONENT_TAGS], + }, async ({ page }) => { + await load(page, { data: { value: 40 }, showTooltip: false }); + + await hoverPie(locators.valuePie(page)); + await page.waitForTimeout(300); + await expect(locators.tooltip(page)).toBeHidden(); + }); + + test('Verify no tooltip on the other pie', { + tag: [TAG.PRIORITY_HIGH, TAG.MOUSE, ...COMPONENT_TAGS], + }, async ({ page }) => { + await load(page, { data: { value: 40 } }); + + await test.step('Verify tooltip is shown on the value pie', async () => { + await hoverPieForTooltip(page, locators.valuePie(page)); + }); + + await test.step('Verify tooltip hides when the mouse moves to the other pie', async () => { + await hoverPie(locators.otherPie(page)); + await expect(locators.tooltip(page)).toBeHidden(); + }); + + await test.step('Verify tooltip does not appear on the other pie from outside', async () => { + await page.mouse.move(0, 0); + await hoverPie(locators.otherPie(page)); + await page.waitForTimeout(300); + await expect(locators.tooltip(page)).toBeHidden(); + }); + }); + }); + + test.describe('Legend', () => { + test('Verify legend toggles the value pie by mouse', { + tag: [TAG.PRIORITY_HIGH, TAG.MOUSE, ...COMPONENT_TAGS, '@chart-legend', '@checkbox'], + }, async ({ page }) => { + await load(page, { showLegend: true }); + + await test.step('Verify value pie is drawn by default', async () => { + await expect(locators.legendCheckbox(page)).toBeChecked(); + await expect(locators.valuePie(page)).toHaveCount(1); + }); + + await test.step('Verify unchecking hides the value pie', async () => { + await locators.legendItem(page).click(); + await expect(locators.legendCheckbox(page)).not.toBeChecked(); + await expect(locators.valuePie(page)).toHaveCount(0); + await expect(locators.emptyData(page)).toHaveCount(1); + }); + + await test.step('Verify checking again brings the value pie back', async () => { + await locators.legendItem(page).click(); + await expect(locators.legendCheckbox(page)).toBeChecked(); + await expect(locators.valuePie(page)).toHaveCount(1); + await expect(locators.emptyData(page)).toHaveCount(0); + }); + }); + + test('Verify legend toggles the value pie by keyboard', { + tag: [TAG.PRIORITY_HIGH, TAG.KEYBOARD, ...COMPONENT_TAGS, '@chart-legend', '@checkbox'], + }, async ({ page, browserName }) => { + test.skip(browserName === 'webkit', 'Tab does not focus checkboxes in WebKit by default'); + await load(page, { showLegend: true }); + + await test.step('Verify checkbox receives focus by Tab', async () => { + await page.keyboard.press('Tab'); + await expect(locators.legendCheckbox(page)).toBeFocused(); + }); + + await test.step('Verify Space toggles the value pie', async () => { + await page.keyboard.press('Space'); + await expect(locators.legendCheckbox(page)).not.toBeChecked(); + await expect(locators.valuePie(page)).toHaveCount(0); + + await page.keyboard.press('Space'); + await expect(locators.legendCheckbox(page)).toBeChecked(); + await expect(locators.valuePie(page)).toHaveCount(1); + }); + }); + + test('Verify unchecked value item does not look like a 100% other ring', { + tag: [TAG.PRIORITY_HIGH, TAG.MOUSE, ...COMPONENT_TAGS, '@chart-legend'], + }, async ({ page }) => { + test.fixme(true, 'B3: the other pie is drawn over EmptyData when the value item is unchecked'); + await load(page, { showLegend: true }); + + await locators.legendItem(page).click(); + await expect(locators.otherPie(page)).toHaveCount(0); + }); + + test('Verify hover on the legend item highlights the value pie', { + tag: [TAG.PRIORITY_MEDIUM, TAG.MOUSE, ...COMPONENT_TAGS, '@chart-legend'], + }, async ({ page }) => { + await load(page, { showLegend: true }); + const idleRadius = await outerRadius(locators.valuePie(page)); + + await locators.legendItem(page).hover(); + await expect.poll(() => outerRadius(locators.valuePie(page))).toBe(idleRadius + 4); + }); + + test('Verify value pie returns to its size after leaving the legend item', { + tag: [TAG.PRIORITY_MEDIUM, TAG.MOUSE, ...COMPONENT_TAGS, '@chart-legend'], + }, async ({ page }) => { + test.fixme(true, 'B9: the value pie stays highlighted after the mouse leaves the legend item'); + await load(page, { showLegend: true }); + const idleRadius = await outerRadius(locators.valuePie(page)); + + await locators.legendItem(page).hover(); + await expect.poll(() => outerRadius(locators.valuePie(page))).toBe(idleRadius + 4); + + await page.mouse.move(0, 0); + await expect.poll(() => outerRadius(locators.valuePie(page))).toBe(idleRadius); + }); + + test('Verify side metric layout hides the legend', { + tag: [TAG.PRIORITY_LOW, ...COMPONENT_TAGS, '@divider'], + }, async ({ page }) => { + await load(page, { withSideMetric: true, data: { value: 3 } }); + + await expect(locators.legend(page)).toBeHidden(); + await expect(page.getByText('71,286')).toBeVisible(); + await expect(page.getByText('value name')).toBeVisible(); + }); + }); + + test.describe('Other pie', () => { + test('Verify mask covers the other pie in a full donut', { + tag: [TAG.PRIORITY_HIGH, ...COMPONENT_TAGS], + }, async ({ page }) => { + await load(page, { data: { value: 40 } }); + + expect(await maskCoversOtherPie(page)).toBe(true); + }); + + test('Verify mask covers the other pie in a semi donut', { + tag: [TAG.PRIORITY_HIGH, ...COMPONENT_TAGS], + }, async ({ page }) => { + await load(page, { data: { value: 40 }, halfsize: true }); + + expect(await maskCoversOtherPie(page)).toBe(true); + }); + + test('Verify other pie is not painted black', { + tag: [TAG.PRIORITY_HIGH, ...COMPONENT_TAGS], + }, async ({ page }) => { + await load(page, { data: { value: 40 } }); + + const fill = await locators.otherPie(page).evaluate((path) => getComputedStyle(path).fill); + expect(fill).not.toBe('rgb(0, 0, 0)'); + }); + + test('Verify other pie gets a single fill method with patterns', { + tag: [TAG.PRIORITY_MEDIUM, ...COMPONENT_TAGS], + }, async ({ page }) => { + await load(page, { data: { value: 40 }, patterns: true }); + + const otherPie = locators.otherPie(page); + const hasPattern = await otherPie.evaluate((path) => (path.getAttribute('pattern') ?? '').startsWith('url(')); + expect(hasPattern).toBe(false); + await expect(otherPie).toHaveAttribute('mask', /^url\(#.+-mask\)$/); + await expect(locators.valuePie(page)).toHaveAttribute('pattern', /^url\(/); + }); + }); + + test.describe('Layout', () => { + test('Verify chart follows the container size', { + tag: [TAG.PRIORITY_MEDIUM, ...COMPONENT_TAGS], + }, async ({ page }) => { + await load(page, { plotWidth: 500, plotHeight: 300 }); + const initialWidth = (await locators.plot(page).boundingBox())!.width; + + await page.locator('[data-testid="TEST"]').evaluate((chart) => { + (chart.parentElement as HTMLElement).style.width = '400px'; + }); + + await expect.poll(async () => (await locators.plot(page).boundingBox())!.width).toBeLessThan(initialWidth); + }); + }); + + test.describe('Accessibility', () => { + test('Verify aria attributes', { + tag: [TAG.PRIORITY_HIGH, ...COMPONENT_TAGS], + }, async ({ page }) => { + await load(page); + + await expect(locators.plot(page)).toHaveAttribute('aria-label', 'Score donut chart'); + await expect(page.locator('[data-testid="TEST"]')).toHaveAttribute('role', 'group'); + await expect(page.locator('[data-ui-name="Donut"]')).toHaveAttribute('aria-hidden', 'true'); + }); + + test('Verify data summary contains both values', { + tag: [TAG.PRIORITY_MEDIUM, TAG.KEYBOARD, ...COMPONENT_TAGS], + }, async ({ page }) => { + await load(page, { data: { value: 40 } }); + + await page.getByRole('button', { name: 'Open data summary' }).focus(); + await page.keyboard.press('Enter'); + + const summary = page.getByText(/Chart represents/); + await expect(summary).toBeVisible(); + await expect(summary).toContainText('40'); + await expect(summary).toContainText('60'); + }); + + test('Verify data summary uses human-readable names', { + tag: [TAG.PRIORITY_LOW, TAG.KEYBOARD, ...COMPONENT_TAGS], + }, async ({ page }) => { + test.fixme(true, 'B8: the data summary reads raw keys ("other of value 60 and value of value 40")'); + await load(page, { data: { value: 40 } }); + + await page.getByRole('button', { name: 'Open data summary' }).focus(); + await page.keyboard.press('Enter'); + + await expect(page.getByText(/Chart represents/)).not.toContainText('of value'); + }); + }); +}); diff --git a/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-innerRadius-0-full-pie-1-chromium-linux.png b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-innerRadius-0-full-pie-1-chromium-linux.png new file mode 100644 index 0000000000..af4bf5211c Binary files /dev/null and b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-innerRadius-0-full-pie-1-chromium-linux.png differ diff --git a/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-innerRadius-0-full-pie-1-firefox-linux.png b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-innerRadius-0-full-pie-1-firefox-linux.png new file mode 100644 index 0000000000..731f7b9587 Binary files /dev/null and b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-innerRadius-0-full-pie-1-firefox-linux.png differ diff --git a/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-innerRadius-0-full-pie-1-webkit-linux.png b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-innerRadius-0-full-pie-1-webkit-linux.png new file mode 100644 index 0000000000..1e5eb4fc4a Binary files /dev/null and b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-innerRadius-0-full-pie-1-webkit-linux.png differ diff --git a/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-0-only-the-other-pie-1-chromium-linux.png b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-0-only-the-other-pie-1-chromium-linux.png new file mode 100644 index 0000000000..07fa6a1bb1 Binary files /dev/null and b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-0-only-the-other-pie-1-chromium-linux.png differ diff --git a/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-0-only-the-other-pie-1-firefox-linux.png b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-0-only-the-other-pie-1-firefox-linux.png new file mode 100644 index 0000000000..2aaebba82f Binary files /dev/null and b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-0-only-the-other-pie-1-firefox-linux.png differ diff --git a/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-0-only-the-other-pie-1-webkit-linux.png b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-0-only-the-other-pie-1-webkit-linux.png new file mode 100644 index 0000000000..ae14d22c6f Binary files /dev/null and b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-0-only-the-other-pie-1-webkit-linux.png differ diff --git a/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-100-only-the-value-pie-1-chromium-linux.png b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-100-only-the-value-pie-1-chromium-linux.png new file mode 100644 index 0000000000..2c2579f8b4 Binary files /dev/null and b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-100-only-the-value-pie-1-chromium-linux.png differ diff --git a/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-100-only-the-value-pie-1-firefox-linux.png b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-100-only-the-value-pie-1-firefox-linux.png new file mode 100644 index 0000000000..52310294e1 Binary files /dev/null and b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-100-only-the-value-pie-1-firefox-linux.png differ diff --git a/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-100-only-the-value-pie-1-webkit-linux.png b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-100-only-the-value-pie-1-webkit-linux.png new file mode 100644 index 0000000000..14c53d59d5 Binary files /dev/null and b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-100-only-the-value-pie-1-webkit-linux.png differ diff --git a/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-50-two-equal-halves-1-chromium-linux.png b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-50-two-equal-halves-1-chromium-linux.png new file mode 100644 index 0000000000..beb13fb12e Binary files /dev/null and b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-50-two-equal-halves-1-chromium-linux.png differ diff --git a/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-50-two-equal-halves-1-firefox-linux.png b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-50-two-equal-halves-1-firefox-linux.png new file mode 100644 index 0000000000..b94bceab3e Binary files /dev/null and b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-50-two-equal-halves-1-firefox-linux.png differ diff --git a/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-50-two-equal-halves-1-webkit-linux.png b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-50-two-equal-halves-1-webkit-linux.png new file mode 100644 index 0000000000..ec40074eb5 Binary files /dev/null and b/semcore/d3-chart/__tests__/score-donut-chart.browser-test.tsx-snapshots/-visual-Verify-score-donut-value-50-two-equal-halves-1-webkit-linux.png differ diff --git a/semcore/d3-chart/package.json b/semcore/d3-chart/package.json index 7a11cf6495..3240f4fa33 100644 --- a/semcore/d3-chart/package.json +++ b/semcore/d3-chart/package.json @@ -25,7 +25,6 @@ "@types/d3-shape": "3.1.7", "@upsetjs/venn.js": "2.0.0", "d3-array": "3.1.6", - "d3-ease": "3.0.1", "d3-interpolate": "3.0.1", "d3-polygon": "3.0.1", "d3-scale": "4.0.2", diff --git a/semcore/d3-chart/src/Donut.jsx b/semcore/d3-chart/src/Donut.jsx index 088f22b063..64d6baad38 100644 --- a/semcore/d3-chart/src/Donut.jsx +++ b/semcore/d3-chart/src/Donut.jsx @@ -1,9 +1,8 @@ +import { Flex } from '@semcore/base-components'; import { Component, Root, sstyled } from '@semcore/core'; import { callAllEventHandlers } from '@semcore/core/lib/utils/assignProps'; -import canUseDOM from '@semcore/core/lib/utils/canUseDOM'; import getOriginChildren from '@semcore/core/lib/utils/getOriginChildren'; import uniqueIDEnhancement from '@semcore/core/lib/utils/uniqueID'; -import { easeCubicOut } from 'd3-ease'; import { interpolate } from 'd3-interpolate'; import { arc, pie } from 'd3-shape'; import { transition } from 'd3-transition'; @@ -23,9 +22,7 @@ function transitionAnglePie({ duration, halfsize, d3Arc, - d3ArcOut, arcs, - activeIndexPie, }) { return transition() .selection() @@ -50,49 +47,17 @@ function transitionAnglePie({ d.startAngle = iStartAngle(t); d.endAngle = iEndAngle(t); self.arc = d; - return ind === activeIndexPie ? d3ArcOut(self.arc) : d3Arc(self.arc); + return d3Arc(self.arc); }; }); } -function transitionRadiusPie({ - data, - selector, - element, - duration, - innerRadius, - outerRadiusStartEnd, - paddingAngle, -}) { - return transition() - .selection() - .select(selector) - .interrupt() - .transition() - .duration(duration) - .ease(easeCubicOut) - .attrTween('d', function () { - const [start, end] = outerRadiusStartEnd; - const iOuterRadius = interpolate(start, end); - return function (t) { - const outerRadiusPX = iOuterRadius(t); - element.dataset['currentRadius'] = outerRadiusPX; - const d3ArcOut = arc() - .innerRadius(innerRadius) - .outerRadius(outerRadiusPX) - .padAngle(paddingAngle); - return d3ArcOut(data); - }; - }); -} - -const increaseFactor = 4; -const donutThickness = 16; +const donutThickness = 12; function getOuterRadius({ size, halfsize }) { const [width, height] = size; const minORmax = halfsize ? Math.max : Math.min; - return minORmax(width - increaseFactor * 2, height - increaseFactor * 2) / 2; + return minORmax(width, height) / 2; } class DonutRoot extends Component { @@ -102,20 +67,16 @@ class DonutRoot extends Component { static defaultProps = ({ innerRadius: innerRadiusProp, - paddingAngle = 0, + paddingAngle, outerRadius, halfsize = false, $rootProps: { size }, }) => { const radius = outerRadius || getOuterRadius({ size, halfsize }); const innerRadius = innerRadiusProp ?? Math.max(radius - donutThickness, 0); + const padAngle = paddingAngle ?? 2 / radius; - const d3Arc = arc().outerRadius(radius).innerRadius(innerRadius).padAngle(paddingAngle); - - const d3ArcOut = arc() - .outerRadius(radius + increaseFactor) - .innerRadius(innerRadius) - .padAngle(paddingAngle); + const d3Arc = arc().outerRadius(radius).innerRadius(innerRadius).padAngle(padAngle).cornerRadius(2); let d3Pie = pie() .sort(null) @@ -128,26 +89,23 @@ class DonutRoot extends Component { return { d3Pie, d3Arc, - d3ArcOut, - duration: 500, + duration: TRANSITION_RADIUS_DURATION, paddingAngle, innerRadius, }; }; + state = { + active: null, + }; + get id() { const { uid, id } = this.asProps; return id || uid; } - virtualElement = canUseDOM() ? document.createElement('div') : {}; - activeIndexPie = undefined; canAnimatedHover = false; - generateGetBoundingClientRect(x = 0, y = 0) { - return () => ({ width: 0, height: 0, top: y, right: x, bottom: y, left: x }); - } - getArcs() { const { Children, data, d3Pie } = this.asProps; let pieData = Object.entries(data); @@ -183,31 +141,11 @@ class DonutRoot extends Component { eventEmitter.emit(`setTooltipVisible_${plotId}`, visible); }; - animationActivePie = ({ data, active, selector, element }) => { - const { duration, d3Arc, paddingAngle } = this.asProps; - const outerRadius = d3Arc.outerRadius()(); - const innerRadius = d3Arc.innerRadius()(); - const outerRadiusStartEnd = active - ? [+element.dataset['currentRadius'] || outerRadius, outerRadius + increaseFactor] - : [+element.dataset['currentRadius'] || outerRadius, outerRadius]; - if (this.canAnimatedHover && duration > 0) { - transitionRadiusPie({ - data, - selector: `#${this.id} ${selector}`, - element, - duration: TRANSITION_RADIUS_DURATION, - innerRadius, - outerRadiusStartEnd, - paddingAngle, - }); - } - }; - animationTimeout = null; animationUpdatePie = () => { clearTimeout(this.animationTimeout); this.animationTimeout = setTimeout(() => { - const { duration, d3Arc, halfsize, d3ArcOut } = this.asProps; + const { duration, d3Arc, halfsize } = this.asProps; this.canAnimatedHover = false; if (duration > 0) { transitionAnglePie({ @@ -216,8 +154,6 @@ class DonutRoot extends Component { arcs: this.arcs, halfsize, d3Arc, - d3ArcOut, - activeIndexPie: this.activeIndexPie, }).on('end', () => { this.canAnimatedHover = true; }); @@ -240,56 +176,40 @@ class DonutRoot extends Component { } getPieProps(props, index) { - const { d3Arc, d3ArcOut, innerRadius, outerRadius, halfsize, resolveColor, uid, patterns } = + const { d3Arc, innerRadius, outerRadius, halfsize, resolveColor, patterns } = this.asProps; - const { active } = props; + const { transparent } = props; const data = this.arcs.find((arc) => arc.data[0] === props.dataKey); - if (active) { - this.activeIndexPie = index; - } + const tooltipProps = { dataKey: props.dataKey, name: props.name, resolveColor, color: props.color || getChartDefaultColorName(index), - active: props.active, transparent: props.transparent, patterns, }; + const pieId = `${this.id}-${index}`; + return { data, d3Arc, - d3ArcOut, innerRadius, outerRadius, - uid: `${uid}-${index}`, + uid: pieId, patterns, halfsize, color: props.color || getChartDefaultColorName(index), resolveColor, - $animationActivePie: this.animationActivePie, + transparent: transparent ?? (this.state.active !== null && this.state.active !== pieId), onMouseMove: this.bindHandlerTooltip(true, props, tooltipProps), onMouseLeave: this.bindHandlerTooltip(false, props, tooltipProps), onMouseOver: (e) => { - if (!active) { - this.animationActivePie({ - active: true, - data, - selector: `[d="${e.target.getAttribute('d')}"]`, - element: e.target, - }); - } + this.setState({ active: pieId }); }, onMouseOut: (e) => { - if (!active) { - this.animationActivePie({ - active: false, - data, - selector: `[d="${e.target.getAttribute('d')}"]`, - element: e.target, - }); - } + this.setState({ active: null }); }, onClickPieRoot: this.handlerOnClick(props.dataKey), }; @@ -302,6 +222,14 @@ class DonutRoot extends Component { }; } + getLabelProps() { + const { halfsize } = this.asProps; + + return { + halfsize, + }; + } + componentDidUpdate(prevProps) { const { data } = this.asProps; if (prevProps.$rootProps.data !== data) { @@ -314,6 +242,7 @@ class DonutRoot extends Component { } render() { + this.activeIndexPie = undefined; const { halfsize, size } = this.asProps; this.asProps.dataHintsHandler.establishDataType('values-set'); const [width, height] = size; @@ -338,49 +267,18 @@ function Pie({ d3Arc, data, color, - $animationActivePie, - active, - d3ArcOut, name, dataKey, dataHintsHandler, transparent, - innerRadius, - outerRadius, resolveColor, - halfsize, uid, patterns, onClick, onClickPieRoot, - ...other }) { - const [isMount, setIsMount] = React.useState(false); const pieRef = React.useRef(null); - React.useEffect(() => { - if (!pieRef.current) return; - pieRef.current.dataset['currentRadius'] = (active ? d3ArcOut : d3Arc).outerRadius()(); - - // do not run animation on first render - if (!isMount) { - setIsMount(true); - return; - } - if (active !== undefined && active !== null) { - $animationActivePie({ - active, - data, - selector: `[name="${other.name}"]`, - element: pieRef.current, - }); - } - }, [active, Boolean(data)]); - React.useEffect(() => { - if (!pieRef.current) return; - pieRef.current.dataset['currentRadius'] = (active ? d3ArcOut : d3Arc).outerRadius()(); - }, [active, innerRadius, outerRadius, halfsize, Boolean(data)]); - if (!data) return null; dataHintsHandler.establishDataType('values-set'); @@ -394,7 +292,7 @@ function Pie({ ref={pieRef} color={resolveColor(color)} pattern={patterns ? `url(#${uid}-pattern)` : undefined} - d={active ? d3ArcOut(data) : d3Arc(data)} + d={d3Arc(data)} transparent={transparent} use:onClick={callAllEventHandlers(onClickPieRoot, onClick)} />, @@ -411,7 +309,7 @@ function Pie({ ); } -function EmptyData({ Element: SEmptyData, styles, d3Arc, color, resolveColor }) { +function EmptyData({ Element: SEmptyData, styles, d3Arc, color, resolveColor, emptyData }) { return sstyled(styles)( - + + + + , ); } diff --git a/semcore/d3-chart/src/Pattern.tsx b/semcore/d3-chart/src/Pattern.tsx index 4f12eddcd6..4ff246cc3d 100644 --- a/semcore/d3-chart/src/Pattern.tsx +++ b/semcore/d3-chart/src/Pattern.tsx @@ -628,30 +628,23 @@ const resolvePattern = (patternKey: string, patternsConfig: PatternsConfig = tru return patterns[patternIndex]; }; -export const DefaultStrokePattern = (props: { id: string }) => { +export const DefaultStrokePattern = (props: { id: string; halfsize?: boolean }) => { return ( - - + + + + + + ); }; diff --git a/semcore/d3-chart/src/Plot.jsx b/semcore/d3-chart/src/Plot.jsx index dd1e850c10..b8964c663e 100644 --- a/semcore/d3-chart/src/Plot.jsx +++ b/semcore/d3-chart/src/Plot.jsx @@ -34,8 +34,8 @@ class PlotRoot extends Component { rootRef = React.createRef(); - dataStructureHints = this.props.dataHints || makeDataHintsContainer(); - dataHintsHandler = makeDataHintsHandlers(this.dataStructureHints); + dataStructureHints = this.props.dataHints ?? makeDataHintsContainer(); + dataHintsHandler = this.props.dataHintsHandlers ?? makeDataHintsHandlers(this.dataStructureHints); handlerMouseMove = (e) => { const { scale } = this.asProps; diff --git a/semcore/d3-chart/src/a11y/DataAccessibilityTable.tsx b/semcore/d3-chart/src/a11y/DataAccessibilityTable.tsx index aa5903f154..286b6858ae 100644 --- a/semcore/d3-chart/src/a11y/DataAccessibilityTable.tsx +++ b/semcore/d3-chart/src/a11y/DataAccessibilityTable.tsx @@ -18,7 +18,7 @@ export const DataAccessibilityTable: React.FC<{ fromHints.push(...hints.fields.horizontalAxes); fromHints.push(...hints.fields.valueAxes); fromHints.push(...config.additionalFields); - fromHints.push(...Object.keys(hints.fields.values)); + fromHints.push(...Array.from(hints.fields.values)); fromHints = [...new Set(fromHints)].filter((key) => key in data[0]); diff --git a/semcore/d3-chart/src/component/Chart/AbstractChart.tsx b/semcore/d3-chart/src/component/Chart/AbstractChart.tsx index 1d0913dfbb..f91e1b328c 100644 --- a/semcore/d3-chart/src/component/Chart/AbstractChart.tsx +++ b/semcore/d3-chart/src/component/Chart/AbstractChart.tsx @@ -18,7 +18,7 @@ import type { BaseChartProps, BaseLegendProps, ListData, ObjectData, ObjectDataK import type { HoverLine, HoverRect } from '../..'; // @ts-ignore import { Plot, XAxis, YAxis } from '../..'; -import { makeDataHintsContainer } from '../../a11y/hints'; +import { makeDataHintsContainer, makeDataHintsHandlers } from '../../a11y/hints'; import style from '../../style/abstract-chart.shadow.css'; import { interpolateValue } from '../../utils'; import ChartLegend, { ChartLegendTable } from '../ChartLegend'; @@ -55,6 +55,7 @@ export abstract class AbstractChart< protected plotPadding = 10; protected dataHints = makeDataHintsContainer(); + protected dataHintsHandlers = makeDataHintsHandlers(this.dataHints); protected chartRef = React.createRef(); protected legendRef = React.createRef(); @@ -680,6 +681,7 @@ export abstract class AbstractChart< width={plotWidth} height={plotHeight} dataHints={this.dataHints} + dataHintsHandlers={this.dataHintsHandlers} a11yAltTextConfig={a11yAltTextConfig} patterns={patterns} duration={duration} diff --git a/semcore/d3-chart/src/component/Chart/DonutChart.tsx b/semcore/d3-chart/src/component/Chart/DonutChart.tsx index b2f079c966..ffa2ceedb7 100644 --- a/semcore/d3-chart/src/component/Chart/DonutChart.tsx +++ b/semcore/d3-chart/src/component/Chart/DonutChart.tsx @@ -73,11 +73,22 @@ class DonutChartComponent extends AbstractChart< dataKey={item.id} name={item.label} color={item.color} - active={highlightedItem === index} + transparent={highlightedItem !== -1 && highlightedItem !== index} + onMouseOver={() => { + this.setState({ highlightedItem: index }); + }} + onMouseOut={() => { + this.setState({ highlightedItem: -1 }); + }} /> ); })} - {innerLabel && {innerLabel}} + {innerLabel && ( + + {innerLabel[0]} + {innerLabel[1]} + + )} ); } diff --git a/semcore/d3-chart/src/component/Chart/DonutChart.type.ts b/semcore/d3-chart/src/component/Chart/DonutChart.type.ts index d7ea7055ce..38701e69d5 100644 --- a/semcore/d3-chart/src/component/Chart/DonutChart.type.ts +++ b/semcore/d3-chart/src/component/Chart/DonutChart.type.ts @@ -23,8 +23,11 @@ export type DonutChartProps = Intergalactic.InternalTypings.EfficientOmit< innerRadius?: number; /** Creates a semi-donut chart when enabled */ halfsize?: boolean; - /** Content displayed in the center of the donut */ - innerLabel?: React.ReactNode; + /** + * Content displayed in the center of the donut + * ["Label (usually total number)", "Description (some text)"] + */ + innerLabel?: [string, string]; /** Callback triggered when a user clicks on a pie */ onClickPie?: (key: DataKey, e: React.SyntheticEvent) => void; }; diff --git a/semcore/d3-chart/src/component/Chart/ScoreDonutChart.tsx b/semcore/d3-chart/src/component/Chart/ScoreDonutChart.tsx new file mode 100644 index 0000000000..6bedc52d5a --- /dev/null +++ b/semcore/d3-chart/src/component/Chart/ScoreDonutChart.tsx @@ -0,0 +1,240 @@ +import { Flex } from '@semcore/base-components'; +import { createComponent, Root, sstyled } from '@semcore/core'; +import { extractAriaProps } from '@semcore/core/lib/utils/ariaProps'; +import i18nEnhance from '@semcore/core/lib/utils/enhances/i18nEnhance'; +import uniqueIDEnhancement from '@semcore/core/lib/utils/uniqueID'; +import { Text } from '@semcore/typography'; +import { scaleLinear } from 'd3-scale'; +import React from 'react'; + +// @ts-ignore +import { Donut, Plot } from '../..'; +import type { ChartState } from './AbstractChart'; +import { NOT_A_VALUE, AbstractChart } from './AbstractChart'; +import type { ScoreDonutChartData, ScoreDonutChartDefaultProps, ScoreDonutChartProps, ScoreDonutChartType } from './ScoreDonutChart.type'; +import { DefaultStrokePattern } from '../../Pattern'; +import { localizedMessages } from '../../translations/__intergalactic-dynamic-locales'; +import { interpolateValue } from '../../utils'; +import type { LegendItem } from '../ChartLegend/LegendItem/LegendItem.type'; + +class ScoreDonutChartComponent extends AbstractChart< + ScoreDonutChartData, + ScoreDonutChartProps, + typeof ScoreDonutChartComponent.enhance, + {}, + ChartState, + ScoreDonutChartDefaultProps +> { + static displayName = 'Chart.ScoreDonut'; + static defaultProps = { + direction: 'row-reverse', + alignItems: 'flex-start', + marginX: 0, + marginY: 0, + locale: 'en', + } as const; + + static enhance = [i18nEnhance(localizedMessages), uniqueIDEnhancement()] as const; + + protected override getDefaultDataDefinitions(): Array { + const { data, legendProps } = this.props; + + const legendData = legendProps?.legendMap?.value; + + // @ts-expect-error will fill columns or rows later + const dataDefinition: LegendItem & ({ columns: Array } | { rows: Array }) = { + id: 'value', + label: legendData?.label ?? 'value', + icon: legendData?.icon ?? undefined, + checked: legendData?.defaultChecked ?? true, + color: this.resolveColor('value', 0), + }; + + if (legendData?.additionalInfo || legendData?.count) { + dataDefinition.additionalInfo = legendData.additionalInfo + ? { label: legendData.additionalInfo } + : legendData.count + ? { count: legendData.count } + : undefined; + } + + if (legendData && 'rows' in legendData && !('columns' in dataDefinition)) { + dataDefinition.rows = legendData.rows || []; + } else if (legendData && 'columns' in legendData && !('rows' in dataDefinition)) { + dataDefinition.columns = legendData.columns || []; + } else if (!('rows' in dataDefinition)) { + const value = data.value; + const percent = value.toFixed(2); + + dataDefinition.columns = [ + + {percent !== undefined ? `${percent}%` : ''} + , + + {value} + , + ]; + } + + return [dataDefinition]; + } + + get xScale() { + const { xScale } = this.asProps; + + if (xScale) { + return xScale; + } + + return scaleLinear(); + } + + get yScale() { + const { yScale } = this.asProps; + + if (yScale) { + return yScale; + } + + return scaleLinear(); + } + + defaultLegendProps() { + return { + legendType: 'Table' as const, + shape: 'Circle' as const, + }; + } + + renderChart() { + const { innerRadius, halfsize, innerLabel, onClickPie, uid } = this.asProps; + const { dataDefinitions, highlightedItem } = this.state; + + const checkedLegendItems = dataDefinitions.filter((item) => item.checked); + + this.dataHintsHandlers.establishDataType('values-set'); + this.dataHintsHandlers.describeValueEntity('', `${this.data.value}%`); + + return ( + + {checkedLegendItems.map((item, index) => { + return ( + { + }, + describeValueEntity: () => { + }, + }} + /> + ); + })} + + { + }, + describeValueEntity: () => { + }, + }} + /> + {innerLabel && {innerLabel}} + + ); + } + + renderTooltip() { + const { data } = this.asProps; + const { dataDefinitions } = this.state; + + return ( + + {({ dataKey }: any) => { + const title = dataDefinitions.find((item) => item.id === dataKey); + + if (!title) { + return { + visible: false, + }; + } + + return { + children: ( + <> + {this.tooltipTitleFormatter(title?.label || dataKey)} + + {this.tooltipValueFormatter(data.value)} + + + ), + }; + }} + + ); + } + + public override render() { + const SChart = Root; + const { styles, data, patterns, a11yAltTextConfig, duration, eventEmitter, showTooltip, locale } = + this.asProps; + const { plotWidth, plotHeight } = this; + + const { extractedAriaProps } = extractAriaProps(this.asProps); + + return sstyled(styles)( + + {this.renderLegend()} + + {this.renderAxis()} + {!showTooltip ? null : this.renderTooltip()} + {this.renderChart()} + + , + ); + } + + private get data() { + return { + value: this.asProps.data.value, + other: 100 - this.asProps.data.value, + }; + } + + protected getLegendAriaLabel(): string { + return this.asProps.getI18nText('legendForChart', { chartType: 'ScoreDonut' }); + } +} + +/** + * ScoreDonutChart + * + * {@link https://developer.semrush.com/intergalactic/data-display/donut-chart/donut-chart-api/#chartscoredonut|API} | {@link https://developer.semrush.com/intergalactic/data-display/donut-chart/donut-chart-code/#score-donut|Examples} + */ +export const ScoreDonutChart = createComponent< + ScoreDonutChartType, + typeof ScoreDonutChartComponent +>(ScoreDonutChartComponent); diff --git a/semcore/d3-chart/src/component/Chart/ScoreDonutChart.type.ts b/semcore/d3-chart/src/component/Chart/ScoreDonutChart.type.ts new file mode 100644 index 0000000000..22abf9da8e --- /dev/null +++ b/semcore/d3-chart/src/component/Chart/ScoreDonutChart.type.ts @@ -0,0 +1,45 @@ +import type { Flex } from '@semcore/base-components'; +import type { Intergalactic } from '@semcore/core'; +import type { ScaleLinear } from 'd3-scale'; +import type React from 'react'; + +import type { BaseChartProps } from './AbstractChart.type'; + +export type DataKey = string; + +export type ScoreDonutChartData = { + /** + * Number is a percent value from 0 to 100. + */ + value: number; +}; + +export type ScoreDonutChartProps = Intergalactic.InternalTypings.EfficientOmit< + BaseChartProps, + 'showTotalInTooltip' | 'showDeltaPercentInTooltip' | 'getPercentDelta' | 'deltaPercentGrowthColor' +> & { + /** Internal */ + groupKey?: never; + /** Custom x-axis scale */ + xScale?: ScaleLinear; + /** Custom y-axis scale */ + yScale?: ScaleLinear; + /** Controls the inner radius of the donut */ + innerRadius?: number; + /** Creates a semi-donut chart when enabled */ + halfsize?: boolean; + /** Content displayed in the center of the donut */ + innerLabel?: React.ReactNode; + /** Callback triggered when a user clicks on a pie */ + onClickPie?: (key: DataKey, e: React.SyntheticEvent) => void; +}; + +export type ScoreDonutChartDefaultProps = { + direction: 'row-reverse'; + alignItems: 'flex-start'; + marginX: 0; + marginY: 0; + locale: 'en'; +}; + +export type ScoreDonutChartType = Intergalactic.Component; diff --git a/semcore/d3-chart/src/component/Chart/index.ts b/semcore/d3-chart/src/component/Chart/index.ts index 0afd4d430c..701e21ba97 100644 --- a/semcore/d3-chart/src/component/Chart/index.ts +++ b/semcore/d3-chart/src/component/Chart/index.ts @@ -9,6 +9,7 @@ import { HistogramChart } from './HistogramChart'; import { LineChart } from './LineChart'; import { RadarChart } from './RadarChart'; import { ScatterPlotChart } from './ScatterPlotChart'; +import { ScoreDonutChart } from './ScoreDonutChart'; import { VennChart } from './VennChart'; export default { @@ -19,6 +20,7 @@ export default { Area: AreaChart, Bubble: BubbleChart, Donut: DonutChart, + ScoreDonut: ScoreDonutChart, Venn: VennChart, Radar: RadarChart, Cigarette: CigaretteChart, diff --git a/semcore/d3-chart/src/style/area.shadow.css b/semcore/d3-chart/src/style/area.shadow.css index 9c9f3da70d..fc389fd669 100644 --- a/semcore/d3-chart/src/style/area.shadow.css +++ b/semcore/d3-chart/src/style/area.shadow.css @@ -17,7 +17,7 @@ SArea[pattern] { } SArea[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SAreaLine { @@ -31,7 +31,7 @@ SAreaLine[color] { } SAreaLine[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SArea, diff --git a/semcore/d3-chart/src/style/bar.shadow.css b/semcore/d3-chart/src/style/bar.shadow.css index ce77902571..9c33d7c5eb 100644 --- a/semcore/d3-chart/src/style/bar.shadow.css +++ b/semcore/d3-chart/src/style/bar.shadow.css @@ -20,7 +20,7 @@ SBar[hide] { } SBar[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SBar[onClick] { diff --git a/semcore/d3-chart/src/style/bubble.shadow.css b/semcore/d3-chart/src/style/bubble.shadow.css index 2334559e22..3cbbdc26e5 100644 --- a/semcore/d3-chart/src/style/bubble.shadow.css +++ b/semcore/d3-chart/src/style/bubble.shadow.css @@ -31,7 +31,7 @@ SBubble[pattern][color] { } SBubble[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SCenter { @@ -46,7 +46,7 @@ SCenter[color] { } SCenter[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SLabel { @@ -66,5 +66,5 @@ SLabel[color] { } SLabel[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } diff --git a/semcore/d3-chart/src/style/donut.shadow.css b/semcore/d3-chart/src/style/donut.shadow.css index 439c0d95e1..f8ec51abf9 100644 --- a/semcore/d3-chart/src/style/donut.shadow.css +++ b/semcore/d3-chart/src/style/donut.shadow.css @@ -1,6 +1,7 @@ SPie { stroke: var(--intergalactic-chart-grid-border, #ffffff); fill: var(--intergalactic-chart-palette-order-1, oklch(0.23 0.009 245)); + transition: opacity calc(var(--intergalactic-duration-extra-fast, 100) * 1ms) ease-out; } SPie[color] { @@ -14,7 +15,7 @@ SPie[pattern][color] { } SPie[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SEmptyData { @@ -24,11 +25,3 @@ SEmptyData { SEmptyData[color] { fill: var(--color); } - -SLabel { - text-anchor: middle; - vertical-anchor: middle; - font-size: var(--intergalactic-fs-200, 14px); - line-height: var(--intergalactic-lh-200, 1.4286); - -} diff --git a/semcore/d3-chart/src/style/dot.shadow.css b/semcore/d3-chart/src/style/dot.shadow.css index b2799cc505..f53bb0befb 100644 --- a/semcore/d3-chart/src/style/dot.shadow.css +++ b/semcore/d3-chart/src/style/dot.shadow.css @@ -49,7 +49,7 @@ SDotBorder { } SDot[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SDotBackdrop { diff --git a/semcore/d3-chart/src/style/line.shadow.css b/semcore/d3-chart/src/style/line.shadow.css index e6f1d75af5..1b4d32d187 100644 --- a/semcore/d3-chart/src/style/line.shadow.css +++ b/semcore/d3-chart/src/style/line.shadow.css @@ -16,7 +16,7 @@ SLine[hide] { } SLine[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SNull { diff --git a/semcore/d3-chart/src/style/radar.shadow.css b/semcore/d3-chart/src/style/radar.shadow.css index fee353df8c..5e45a369b2 100644 --- a/semcore/d3-chart/src/style/radar.shadow.css +++ b/semcore/d3-chart/src/style/radar.shadow.css @@ -53,7 +53,7 @@ SPolygonDot { } SPolygonDot[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SPolygonDot[color] { @@ -67,7 +67,7 @@ SPolygonLine { } SPolygonLine[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SPolygonLine[color] { diff --git a/semcore/d3-chart/src/style/radial-tree.shadow.css b/semcore/d3-chart/src/style/radial-tree.shadow.css index 52e48b78d6..b558ef4571 100644 --- a/semcore/d3-chart/src/style/radial-tree.shadow.css +++ b/semcore/d3-chart/src/style/radial-tree.shadow.css @@ -12,7 +12,7 @@ SLine[stroke] { } SLine[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SCap { @@ -25,11 +25,11 @@ SCap[pattern] { } SCap[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SIcon[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SLabel { @@ -37,7 +37,7 @@ SLabel { } SLabel[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SRadian:hover SLabel { diff --git a/semcore/d3-chart/src/style/scatterplot.shadow.css b/semcore/d3-chart/src/style/scatterplot.shadow.css index 777d05dd51..4d4fc26ff3 100644 --- a/semcore/d3-chart/src/style/scatterplot.shadow.css +++ b/semcore/d3-chart/src/style/scatterplot.shadow.css @@ -21,7 +21,7 @@ SScatterPlot[pattern][color] { } SScatterPlot[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SValue { @@ -35,5 +35,5 @@ SValue[color] { } SValue[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } diff --git a/semcore/d3-chart/src/style/venn.shadow.css b/semcore/d3-chart/src/style/venn.shadow.css index bf1bf16338..4f1a742e05 100644 --- a/semcore/d3-chart/src/style/venn.shadow.css +++ b/semcore/d3-chart/src/style/venn.shadow.css @@ -28,7 +28,7 @@ SCircle[pattern][color] { } SCircle[transparent] { - opacity: 0.3; + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); } SIntersection { @@ -42,5 +42,5 @@ SIntersection { } SIntersection[transparent] { - opacity: 0.3; -} \ No newline at end of file + opacity: var(--intergalactic-chart-transparent-opacity, 0.3); +} diff --git a/semcore/d3-chart/src/types/Plot.d.ts b/semcore/d3-chart/src/types/Plot.d.ts index 91127f4aa6..ea7ef56e59 100644 --- a/semcore/d3-chart/src/types/Plot.d.ts +++ b/semcore/d3-chart/src/types/Plot.d.ts @@ -1,7 +1,7 @@ import type { NSBox } from '@semcore/base-components'; import type { UnknownProperties, Intergalactic } from '@semcore/core'; -import type { DataStructureHints } from './a11y/hints'; +import type { DataStructureHints, makeDataHintHandler } from './a11y/hints'; import type { Context } from './context'; import type { PatternsConfig } from './Pattern'; import type { PlotEventEmitter } from './utils'; @@ -27,6 +27,11 @@ export type PlotProps = Context & * Optional container for data hints (use it if you have a legend component upper in tree) */ dataHints?: DataStructureHints; + + /** + * Optional container for data hints handlers (use it if you have a legend component upper in tree) + */ + dataHintsHandlers?: typeof makeDataHintHandler; /** Optional prop to tune up alt text generating for charts */ a11yAltTextConfig?: PlotSummarizerConfig; diff --git a/semcore/d3-chart/src/types/index.d.ts b/semcore/d3-chart/src/types/index.d.ts index c92b2c003b..ed60147b45 100644 --- a/semcore/d3-chart/src/types/index.d.ts +++ b/semcore/d3-chart/src/types/index.d.ts @@ -103,6 +103,7 @@ export * from './component/Chart/ScatterPlotChart.type'; export * from './component/Chart/AreaChart.type'; export * from './component/Chart/BubbleChart.type'; export * from './component/Chart/DonutChart.type'; +export * from './component/Chart/ScoreDonutChart.type'; export * from './component/Chart/VennChart.type'; export * from './component/Chart/RadarChart.type'; export * from './component/Chart/ScatterPlotChart.type'; diff --git a/semcore/skeleton/src/svg/donut-chart-halfsize.svg b/semcore/skeleton/src/svg/donut-chart-halfsize.svg index b35a514089..d21241bbb0 100644 --- a/semcore/skeleton/src/svg/donut-chart-halfsize.svg +++ b/semcore/skeleton/src/svg/donut-chart-halfsize.svg @@ -1,4 +1,5 @@ - - - + + + + diff --git a/semcore/skeleton/src/svg/donut-chart.svg b/semcore/skeleton/src/svg/donut-chart.svg index b6de3bb6b0..3e721e62a5 100644 --- a/semcore/skeleton/src/svg/donut-chart.svg +++ b/semcore/skeleton/src/svg/donut-chart.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/stories/components/d3-chart/__mocks__/donut.ts b/stories/components/d3-chart/__mocks__/donut.ts index baf6b99ffe..e44fc9bf2b 100644 --- a/stories/components/d3-chart/__mocks__/donut.ts +++ b/stories/components/d3-chart/__mocks__/donut.ts @@ -9,4 +9,10 @@ export default { b: 0, c: 0, }, + WithOther: { + a: 5, + b: 8, + c: 4, + other: 2, + }, }; diff --git a/stories/components/d3-chart/docs/donut-chart.docs.stories.tsx b/stories/components/d3-chart/docs/donut-chart.docs.stories.tsx index 63b861e10c..01aa49f97a 100644 --- a/stories/components/d3-chart/docs/donut-chart.docs.stories.tsx +++ b/stories/components/d3-chart/docs/donut-chart.docs.stories.tsx @@ -2,7 +2,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import BasicUsageExample from './examples/donut-chart/basic-usage'; import DonutExample from './examples/donut-chart/donut'; -import DonutControlledHighlightExample from './examples/donut-chart/donut-controlled-highlight'; import EdgeCasesExample from './examples/donut-chart/edge-cases'; import LegendAndPatternFillExample from './examples/donut-chart/legend-and-pattern-fill'; import SemiDonutExample from './examples/donut-chart/semi-donut'; @@ -13,7 +12,7 @@ const meta: Meta = { export default meta; -export const Donut: StoryObj = { +export const DonutWithOther: StoryObj = { render: DonutExample, }; @@ -21,10 +20,6 @@ export const BasicUsage: StoryObj = { render: BasicUsageExample, }; -export const DonutControlledHighlight: StoryObj = { - render: DonutControlledHighlightExample, -}; - export const EdgeCases: StoryObj = { render: EdgeCasesExample, }; diff --git a/stories/components/d3-chart/docs/examples/donut-chart/basic-usage.tsx b/stories/components/d3-chart/docs/examples/donut-chart/basic-usage.tsx index 1738fb2880..4cde60477c 100644 --- a/stories/components/d3-chart/docs/examples/donut-chart/basic-usage.tsx +++ b/stories/components/d3-chart/docs/examples/donut-chart/basic-usage.tsx @@ -5,8 +5,8 @@ import DonutMockData from '../../../__mocks__/donut'; const Demo = () => { return ( -
- +
+
); }; diff --git a/stories/components/d3-chart/docs/examples/donut-chart/donut-controlled-highlight.tsx b/stories/components/d3-chart/docs/examples/donut-chart/donut-controlled-highlight.tsx index 8396c3905d..e69de29bb2 100644 --- a/stories/components/d3-chart/docs/examples/donut-chart/donut-controlled-highlight.tsx +++ b/stories/components/d3-chart/docs/examples/donut-chart/donut-controlled-highlight.tsx @@ -1,57 +0,0 @@ -import { Flex } from '@semcore/ui/base-components'; -import Checkbox from '@semcore/ui/checkbox'; -import { Donut, Plot } from '@semcore/ui/d3-chart'; -import React from 'react'; - -import DonutMockData from '../../../__mocks__/donut'; - -const Demo = () => { - const [selected, setSelected] = React.useState(['b']); - const handleCheckboxToggle = React.useCallback( - (name: any) => () => { - setSelected((selected) => { - if (selected.includes(name)) { - return selected.filter((selectedName) => selectedName !== name); - } else { - return [...selected, name]; - } - }); - }, - [setSelected], - ); - - return ( - - - - {Object.keys(data).map((name, index) => ( - - ))} - - - - {Object.keys(data).map((name, index) => { - return ( - - - {`Option ${name.toUpperCase()}`} - - ); - })} - - - ); -}; - -const data = DonutMockData.Default; - -export default Demo; diff --git a/stories/components/d3-chart/docs/examples/donut-chart/donut.tsx b/stories/components/d3-chart/docs/examples/donut-chart/donut.tsx index 5c93a795c4..1f511a9b48 100644 --- a/stories/components/d3-chart/docs/examples/donut-chart/donut.tsx +++ b/stories/components/d3-chart/docs/examples/donut-chart/donut.tsx @@ -1,42 +1,22 @@ -import { Flex } from '@semcore/ui/base-components'; -import { Donut, Plot } from '@semcore/ui/d3-chart'; -import { Text } from '@semcore/ui/typography'; +import { Chart } from '@semcore/ui/d3-chart'; import React from 'react'; import DonutMockData from '../../../__mocks__/donut'; const Demo = () => { return ( - - - - - - - - Example - - - - - {({ dataKey, name }) => { - return { - children: ( - <> - {name} - - {/* @ts-ignore */} - {data[dataKey]} - - - ), - }; - }} - - +
+ +
); }; -const data = DonutMockData.Default; +const data = DonutMockData.WithOther; export default Demo; diff --git a/stories/components/d3-chart/docs/examples/donut-chart/edge-cases.tsx b/stories/components/d3-chart/docs/examples/donut-chart/edge-cases.tsx index cb282a06f3..39c9289892 100644 --- a/stories/components/d3-chart/docs/examples/donut-chart/edge-cases.tsx +++ b/stories/components/d3-chart/docs/examples/donut-chart/edge-cases.tsx @@ -5,7 +5,7 @@ import DonutMockData from '../../../__mocks__/donut'; const Demo = () => { return ( - + diff --git a/stories/components/d3-chart/docs/examples/donut-chart/legend-and-pattern-fill.tsx b/stories/components/d3-chart/docs/examples/donut-chart/legend-and-pattern-fill.tsx index af7e6a0aae..876fd6656f 100644 --- a/stories/components/d3-chart/docs/examples/donut-chart/legend-and-pattern-fill.tsx +++ b/stories/components/d3-chart/docs/examples/donut-chart/legend-and-pattern-fill.tsx @@ -7,8 +7,8 @@ import DonutMockData from '../../../__mocks__/donut'; const dataHints = makeDataHintsContainer(); const Demo = () => { - const width = 250; - const height = 250; + const width = 180; + const height = 180; const [legendItems, setLegendItems] = React.useState( Object.keys(data).map((item, index) => { diff --git a/stories/components/d3-chart/docs/examples/donut-chart/semi-donut.tsx b/stories/components/d3-chart/docs/examples/donut-chart/semi-donut.tsx index a7baab3048..e1c3e84c0b 100644 --- a/stories/components/d3-chart/docs/examples/donut-chart/semi-donut.tsx +++ b/stories/components/d3-chart/docs/examples/donut-chart/semi-donut.tsx @@ -7,13 +7,13 @@ import DonutMockData from '../../../__mocks__/donut'; const Demo = () => { return ( - + - + 71,240 diff --git a/stories/components/d3-chart/docs/examples/score-donut-chart/basic-usage.tsx b/stories/components/d3-chart/docs/examples/score-donut-chart/basic-usage.tsx new file mode 100644 index 0000000000..af865f0fb0 --- /dev/null +++ b/stories/components/d3-chart/docs/examples/score-donut-chart/basic-usage.tsx @@ -0,0 +1,12 @@ +import { Chart } from '@semcore/ui/d3-chart'; +import React from 'react'; + +const Demo = () => { + return ( +
+ +
+ ); +}; + +export default Demo; diff --git a/stories/components/d3-chart/docs/examples/score-donut-chart/semi-donut.tsx b/stories/components/d3-chart/docs/examples/score-donut-chart/semi-donut.tsx new file mode 100644 index 0000000000..af8bfef98a --- /dev/null +++ b/stories/components/d3-chart/docs/examples/score-donut-chart/semi-donut.tsx @@ -0,0 +1,12 @@ +import { Chart } from '@semcore/ui/d3-chart'; +import React from 'react'; + +const Demo = () => { + return ( +
+ +
+ ); +}; + +export default Demo; diff --git a/stories/components/d3-chart/docs/score-donut-chart.docs.stories.tsx b/stories/components/d3-chart/docs/score-donut-chart.docs.stories.tsx new file mode 100644 index 0000000000..31c91f1af2 --- /dev/null +++ b/stories/components/d3-chart/docs/score-donut-chart.docs.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import BasicUsageExample from './examples/score-donut-chart/basic-usage'; +import HalfSizeExample from './examples/score-donut-chart/semi-donut'; + +const meta: Meta = { + title: 'Components/d3Charts/Documentation/Score-Donut-Chart', +}; + +export default meta; + +export const BasicUsage: StoryObj = { + render: BasicUsageExample, +}; + +export const SemiDonut: StoryObj = { + render: HalfSizeExample, +}; diff --git a/stories/components/d3-chart/tests/examples/donut-chart/basic-usage.tsx b/stories/components/d3-chart/tests/examples/donut-chart/basic-usage.tsx index 82d2a81d85..5ad45f4a7c 100644 --- a/stories/components/d3-chart/tests/examples/donut-chart/basic-usage.tsx +++ b/stories/components/d3-chart/tests/examples/donut-chart/basic-usage.tsx @@ -19,6 +19,7 @@ const Demo = (props: DonutChartProps) => { > @@ -33,6 +34,8 @@ const data = { const propsFromHelper = getChartProps({ data, + innerLabel: '71,240', + innerDescription: 'Description', }); // Remove showLegend from defaultProps to allow DonutChart's internal logic to work diff --git a/stories/components/d3-chart/tests/examples/score-donut-chart/basic-usage.tsx b/stories/components/d3-chart/tests/examples/score-donut-chart/basic-usage.tsx new file mode 100644 index 0000000000..c3175eace3 --- /dev/null +++ b/stories/components/d3-chart/tests/examples/score-donut-chart/basic-usage.tsx @@ -0,0 +1,153 @@ +import DiffUp from '@semcore/icon/DiffUp/m'; +import { Box, Flex } from '@semcore/ui/base-components'; +import type { ScoreDonutChartProps } from '@semcore/ui/d3-chart'; +import { Chart, Metric } from '@semcore/ui/d3-chart'; +import Divider from '@semcore/ui/divider'; +import { Text } from '@semcore/ui/typography'; +import React from 'react'; + +import { getChartProps, getPropsToChart } from '../stories_props_helper'; + +/** + * `tooltipValueFormatter` receives `data.value` for both slices, so it formats the score + * as a percent. + */ +function formatValue(value: unknown) { + if (typeof value === 'number' && !Number.isNaN(value)) { + return `${value}% score`; + } + + return String(value); +} + +type ScoreDonutChartStoryProps = Omit & { + patterns?: ScoreDonutChartProps['patterns'] | false; + /** + * `innerLabel` is a ReactNode and cannot travel through `loadPage` as JSON, so the story + * builds it. Turning it on is the only way to reach the `Donut.Label` branch. + */ + withInnerLabel?: boolean; + /** Set to true to replace the built-in tooltip value formatter with `formatValue`. */ + useCustomValueFormatter?: boolean; + /** + * Sets `legendProps.legendMap.value.label`. Empty string keeps the default `value` label. + * It is the only way to reach the `legendData.label` branch of `getDefaultDataDefinitions`. + */ + legendLabel?: string; + /** + * Hides the legend and renders a `Metric` (value + diff), a description and a divider to the + * right of the donut, as in the "Donut chart + ChartLegend" Figma layout. The chart has no + * slot for it, so the story composes it next to the chart. + */ + withSideMetric?: boolean; +}; + +/** Storybook args: the story props plus the flattened legend type the helper un-flattens. */ +type ScoreDonutChartStoryArgs = ScoreDonutChartStoryProps & { + 'legendProps.legendType'?: 'Flex' | 'Table'; +}; + +type DemoComponent = ((props: ScoreDonutChartStoryArgs) => React.ReactElement) & { + defaultProps: ScoreDonutChartStoryArgs; +}; + +const Demo: DemoComponent = (props: ScoreDonutChartStoryArgs) => { + const { + plotWidth, + plotHeight, + withInnerLabel, + useCustomValueFormatter, + legendLabel, + withSideMetric, + ...chartProps + } = getPropsToChart(props); + + const typedChartProps = chartProps as ScoreDonutChartProps; + const { legendProps: _legendProps, ...chartPropsWithoutLegend } = typedChartProps; + // A custom legend label is meaningful only with a visible legend, so the override also + // pins `showLegend: true` — the branch of the props union that accepts `legendProps`. + const resolvedChartProps: ScoreDonutChartProps = withSideMetric + ? { ...chartPropsWithoutLegend, showLegend: false } + : legendLabel && typedChartProps.showLegend !== false + ? { + ...typedChartProps, + showLegend: true, + legendProps: { ...typedChartProps.legendProps, legendMap: { value: { label: legendLabel } } }, + } + : typedChartProps; + + const onClickPieHandler = (key: string, event: React.SyntheticEvent) => { + console.log('Clicked score donut pie:'); + console.log('→ Key:', key); + console.log('→ Event:', event); + }; + + const chart = ( + + {`${chartProps.data.value}%`} +
+ ), + } + : {})} + {...(useCustomValueFormatter ? { tooltipValueFormatter: formatValue } : {})} + // The Figma layout uses a 180×180 donut; in a row the chart cannot measure its own size. + {...(withSideMetric ? { plotWidth: 180, plotHeight: 180, w: 180, flex: 'none' } : {})} + onClickPie={onClickPieHandler} + /> + ); + + return ( + + {withSideMetric + ? ( + + {chart} + + + + value name + + + + + ) + : chart} + + ); +}; + +export const defaultProps: ScoreDonutChartStoryArgs = { + ...getChartProps({ + data: { value: 40 }, + // Mirrors the component defaults. `innerRadius` is left out: Donut derives it from the + // ring thickness when it is absent, and any fixed number here would change the look. + marginX: 0, + marginY: 0, + halfsize: false, + patterns: false, + withInnerLabel: false, + useCustomValueFormatter: false, + legendLabel: '', + withSideMetric: false, + }), + // The component defaults to a Table legend; the helper's baseline is Flex. + 'legendProps.legendType': 'Table', +}; + +Demo.defaultProps = defaultProps; + +export default Demo; diff --git a/stories/components/d3-chart/tests/score-donut-chart.stories.tsx b/stories/components/d3-chart/tests/score-donut-chart.stories.tsx new file mode 100644 index 0000000000..a08783da2a --- /dev/null +++ b/stories/components/d3-chart/tests/score-donut-chart.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta } from '@storybook/react-vite'; + +import BasicUsageExample, { defaultProps as basicUsageProps } from './examples/score-donut-chart/basic-usage'; +import { getChartArgTypes, legendTypeControl } from './examples/stories_props_helper'; + +const meta: Meta = { + title: 'Components/d3Charts/Tests/Score-Donut-Chart', +}; + +export default meta; + +const hidden = { table: { disable: true } } as const; + +export const BasicUsage = { + render: BasicUsageExample, + argTypes: getChartArgTypes({ + ...legendTypeControl, + data: { control: { type: 'object' } }, + innerRadius: { control: { type: 'number', min: 0, max: 150 } }, + halfsize: { control: { type: 'boolean' } }, + withInnerLabel: { control: { type: 'boolean' } }, + useCustomValueFormatter: { control: { type: 'boolean' } }, + legendLabel: { control: 'text' }, + withSideMetric: { control: { type: 'boolean' } }, + // Omitted from ScoreDonutChartProps. + showTotalInTooltip: hidden, + showDeltaPercentInTooltip: hidden, + deltaPercentGrowthColor: hidden, + // Not used by a donut: there are no axes and one tooltip per slice. + invertAxis: hidden, + showXAxis: hidden, + showYAxis: hidden, + xTicksCount: hidden, + yTicksCount: hidden, + multilineXTicks: hidden, + multilineYTicks: hidden, + tooltipViewType: hidden, + showPercentValueInTooltip: hidden, + }), + args: basicUsageProps, +}; diff --git a/tools/theme/src/theme.ts b/tools/theme/src/theme.ts index 56e3627a13..802f029fd2 100644 --- a/tools/theme/src/theme.ts +++ b/tools/theme/src/theme.ts @@ -712,6 +712,10 @@ export const theme: Theme = { value: colors.yellow['200'].value, description: 'Color for the transactional data intent on the chart.', }, + chart_transparent_opacity: { + value: '0.3', + description: 'Opacity for inactive parts of charts.', + }, chart_grid_bar_base_bg: { value: neutral.at(L_BG_MEDIUM), description: 'Default background color of a bar in the BarChart.', @@ -3380,6 +3384,9 @@ type SemanticColors = { }; }; chart: { + transparent: { + opacity: Value; + }; data: { success: Value; warning: Value; diff --git a/website/docs/data-display/donut-chart/donut-chart-api.md b/website/docs/data-display/donut-chart/donut-chart-api.md index ecc4c19df6..613ba02c8a 100644 --- a/website/docs/data-display/donut-chart/donut-chart-api.md +++ b/website/docs/data-display/donut-chart/donut-chart-api.md @@ -32,4 +32,12 @@ import { Donut } from '@semcore/ui/d3-chart'; +## Chart.ScoreDonut + +```js +import { Chart } from '@semcore/ui/d3-chart'; +``` + + + diff --git a/website/docs/data-display/donut-chart/donut-chart-code.md b/website/docs/data-display/donut-chart/donut-chart-code.md index 8ab4406565..1574df538a 100644 --- a/website/docs/data-display/donut-chart/donut-chart-code.md +++ b/website/docs/data-display/donut-chart/donut-chart-code.md @@ -18,11 +18,9 @@ For core principles, concept description, API and changelog, refer to the [D3 ch ::: -## Advanced usage +## Donut with "other value" -- You can draw donut and pie charts with the `Donut` component. -- `Pie` is a separate sector. -- `Label` is a text label inside the chart. +- You should add custom color (use `chart-palette-order-other-data` token) by key in your data for "other value". ::: sandbox @@ -90,3 +88,27 @@ Note that for ChartLegend `patterns` property works only with default `shape={'C ::: + +## Score donut + +Shows one score on a 0–100 scale. The unfilled part of the ring is the rest of the scale. + +::: sandbox + + + +::: + +## Score semi-donut + +Set `halfsize` and use half the plot height when the chart should read as a gauge. + +::: sandbox + + + +::: diff --git a/website/docs/data-display/donut-chart/donut-chart.md b/website/docs/data-display/donut-chart/donut-chart.md index c0786cc790..c43156c416 100644 --- a/website/docs/data-display/donut-chart/donut-chart.md +++ b/website/docs/data-display/donut-chart/donut-chart.md @@ -133,6 +133,21 @@ Display name and value in tooltips. Disable tooltips for very small charts. ## Edge cases +### One data set + +If you need to show one data set, use `ScoreDonut`. This chart shows a single score on a 0–100 scale as a donut or a semi-donut. The filled part is the score. The rest of the ring is the remaining scale, not a second category. + +![](static/score-donut.png) + +Use it when one number should be read as a score, completion, or share of a maximum: + +- Use the full donut when the score is the main metric in the widget and the value can sit in the center. +- Use the half-size chart when the widget is narrow or the chart sits next to a legend. + +Do not use it to compare several categories. For parts of a whole, use the donut chart described above. For a tiny inline score, use the [Mini chart](/data-display/mini-chart/mini-chart) score donut. + +See [ScoreDonut examples](/data-display/donut-chart/donut-chart-code#score-donut). + ### Null values Show zeros in the legend but not on the chart. Show 0 as the values in the legend, use `--text-secondary` color. diff --git a/website/docs/data-display/donut-chart/static/score-donut.png b/website/docs/data-display/donut-chart/static/score-donut.png new file mode 100644 index 0000000000..72e475573f Binary files /dev/null and b/website/docs/data-display/donut-chart/static/score-donut.png differ diff --git a/website/docs/style/design-tokens/design-tokens.json b/website/docs/style/design-tokens/design-tokens.json index 06a1912c17..0340dd5177 100644 --- a/website/docs/style/design-tokens/design-tokens.json +++ b/website/docs/style/design-tokens/design-tokens.json @@ -739,6 +739,14 @@ "description": "Color for the transactional data intent on the chart.", "components": [] }, + { + "name": "--intergalactic-chart-transparent-opacity", + "value": "0.3", + "description": "Opacity for inactive parts of charts.", + "components": [ + "d3-chart" + ] + }, { "name": "--intergalactic-chart-grid-bar-base-bg", "value": "oklch(0.92 0.008 233.5)",