diff --git a/src/RscChart.tsx b/src/RscChart.tsx index a6f956521..c69ced50d 100644 --- a/src/RscChart.tsx +++ b/src/RscChart.tsx @@ -29,6 +29,7 @@ import useSpec from '@hooks/useSpec'; import useSpecProps from '@hooks/useSpecProps'; import useTooltips from '@hooks/useTooltips'; import { getColorValue } from '@specBuilder/specUtils'; +import { createLeafValues } from '@specBuilder/sunburst/sunburstDataModificationUtils'; import { getChartConfig } from '@themes/spectrumTheme'; import { debugLog, @@ -37,6 +38,7 @@ import { sanitizeRscChartChildren, setSelectedSignals, } from '@utils'; +import { Sunburst } from 'alpha/components'; import { renderToStaticMarkup } from 'react-dom/server'; import { Item } from 'vega'; import { Handler, Position, Options as TooltipOptions } from 'vega-tooltip'; @@ -52,6 +54,7 @@ import { LegendDescription, MarkBounds, RscChartProps, + SunburstProps, TooltipAnchor, TooltipPlacement, } from './types'; @@ -112,6 +115,16 @@ export const RscChart = forwardRef( const sanitizedChildren = sanitizeRscChartChildren(props.children); + //NOTE: I don't love this pattern. I don't want to modify user data outside transforms. I just couldn't figure out the right transforms to get the data in the right way + // to make this work. If this wasn't a garage week project, I'd take more time to do transforms to get the data right instead of manually doing this + sanitizedChildren.forEach((child) => { + if (child.type.name === Sunburst.displayName) { + const sunburstProps = child.props as unknown as SunburstProps; + const { id, parentKey, metric = 'value' } = sunburstProps; + createLeafValues(data, id, parentKey, metric); + } + }); + // THE MAGIC, builds our spec const spec = useSpec({ backgroundColor, diff --git a/src/alpha/components/Sunburst/Sunburst.tsx b/src/alpha/components/Sunburst/Sunburst.tsx new file mode 100644 index 000000000..338a23c6a --- /dev/null +++ b/src/alpha/components/Sunburst/Sunburst.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { FC } from 'react'; + +import { DEFAULT_COLOR, DEFAULT_METRIC } from '@constants'; + +import { SunburstProps } from '../../../types'; + +// destructure props here and set defaults so that storybook can pick them up +const Sunburst: FC = ({ + children, + color = DEFAULT_COLOR, + metric = DEFAULT_METRIC, + name, + id, + parentKey, +}) => { + return null; +}; + +// displayName is used to validate the component type in the spec builder +Sunburst.displayName = 'Sunburst'; + +export { Sunburst }; diff --git a/src/alpha/components/Sunburst/index.ts b/src/alpha/components/Sunburst/index.ts new file mode 100644 index 000000000..e9398f530 --- /dev/null +++ b/src/alpha/components/Sunburst/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './Sunburst'; diff --git a/src/alpha/components/index.ts b/src/alpha/components/index.ts index e29b443f4..e67f63310 100644 --- a/src/alpha/components/index.ts +++ b/src/alpha/components/index.ts @@ -10,4 +10,5 @@ * governing permissions and limitations under the License. */ -export * from './Combo' \ No newline at end of file +export * from './Combo'; +export * from './Sunburst'; diff --git a/src/specBuilder/chartSpecBuilder.ts b/src/specBuilder/chartSpecBuilder.ts index a141fe163..5fc7e08fd 100644 --- a/src/specBuilder/chartSpecBuilder.ts +++ b/src/specBuilder/chartSpecBuilder.ts @@ -29,7 +29,7 @@ import { TABLE, } from '@constants'; import { Area, Axis, Bar, Legend, Line, Scatter, Title } from '@rsc'; -import { Combo } from '@rsc/alpha'; +import { Combo, Sunburst } from '@rsc/alpha'; import { BigNumber, Donut } from '@rsc/rc'; import colorSchemes from '@themes/colorSchemes'; import { produce } from 'immer'; @@ -54,6 +54,7 @@ import { Opacities, SanitizedSpecProps, ScatterElement, + SunburstElement, SymbolShapes, SymbolSize, TitleElement, @@ -80,6 +81,7 @@ import { getVegaSymbolSizeFromRscSymbolSize, initializeSpec, } from './specUtils'; +import { addSunburst } from './sunburst/sunburstSpecBuilder'; import { addTitle } from './title/titleSpecBuilder'; export function buildSpec(props: SanitizedSpecProps) { @@ -110,14 +112,24 @@ export function buildSpec(props: SanitizedSpecProps) { buildOrder.set(Bar, 0); buildOrder.set(Line, 0); buildOrder.set(Donut, 0); + buildOrder.set(Sunburst, 0); buildOrder.set(Scatter, 0); buildOrder.set(Combo, 0); buildOrder.set(Legend, 1); buildOrder.set(Axis, 2); buildOrder.set(Title, 3); - let { areaCount, axisCount, barCount, comboCount, donutCount, legendCount, lineCount, scatterCount } = - initializeComponentCounts(); + let { + areaCount, + axisCount, + barCount, + comboCount, + donutCount, + sunburstCount, + legendCount, + lineCount, + scatterCount, + } = initializeComponentCounts(); const specProps = { colorScheme, idKey, highlightedItem }; spec = [...children] .sort((a, b) => buildOrder.get(a.type) - buildOrder.get(b.type)) @@ -144,6 +156,9 @@ export function buildSpec(props: SanitizedSpecProps) { case Donut.displayName: donutCount++; return addDonut(acc, { ...(cur as DonutElement).props, ...specProps, index: donutCount }); + case Sunburst.displayName: + sunburstCount++; + return addSunburst(acc, { ...(cur as SunburstElement).props, ...specProps, index: sunburstCount }); case Legend.displayName: legendCount++; return addLegend(acc, { @@ -202,6 +217,7 @@ const initializeComponentCounts = () => { barCount: -1, comboCount: -1, donutCount: -1, + sunburstCount: -1, legendCount: -1, lineCount: -1, scatterCount: -1, diff --git a/src/specBuilder/chartTooltip/chartTooltipUtils.ts b/src/specBuilder/chartTooltip/chartTooltipUtils.ts index b11276973..38d742577 100644 --- a/src/specBuilder/chartTooltip/chartTooltipUtils.ts +++ b/src/specBuilder/chartTooltip/chartTooltipUtils.ts @@ -32,9 +32,16 @@ import { DonutSpecProps, LineSpecProps, ScatterSpecProps, + SunburstSpecProps, } from '../../types'; -type TooltipParentProps = AreaSpecProps | BarSpecProps | DonutSpecProps | LineSpecProps | ScatterSpecProps; +type TooltipParentProps = + | AreaSpecProps + | BarSpecProps + | DonutSpecProps + | LineSpecProps + | ScatterSpecProps + | SunburstSpecProps; /** * gets all the tooltips @@ -79,7 +86,7 @@ export const addTooltipData = (data: Data[], markProps: TooltipParentProps, addH if (!filteredTable.transform) { filteredTable.transform = []; } - if (highlightBy === 'dimension' && markProps.markType !== 'donut') { + if (highlightBy === 'dimension' && markProps.markType !== 'donut' && markProps.markType !== 'sunburst') { filteredTable.transform.push(getGroupIdTransform([markProps.dimension], markName)); } else if (highlightBy === 'series') { filteredTable.transform.push(getGroupIdTransform([SERIES_ID], markName)); diff --git a/src/specBuilder/marks/markUtils.ts b/src/specBuilder/marks/markUtils.ts index 2939cade3..f348e7ce3 100644 --- a/src/specBuilder/marks/markUtils.ts +++ b/src/specBuilder/marks/markUtils.ts @@ -67,6 +67,7 @@ import { OpacityFacet, ProductionRuleTests, ScaleType, + SunburstSpecProps, SymbolSizeFacet, } from '../../types'; @@ -413,7 +414,9 @@ const getHoverSizeSignal = (size: number): SignalRef => ({ * @param props * @returns */ -export const getMarkOpacity = (props: BarSpecProps | DonutSpecProps): ({ test?: string } & NumericValueRef)[] => { +export const getMarkOpacity = ( + props: BarSpecProps | DonutSpecProps | SunburstSpecProps +): ({ test?: string } & NumericValueRef)[] => { const { children, highlightedItem, idKey, name: markName } = props; const rules: ({ test?: string } & NumericValueRef)[] = [DEFAULT_OPACITY_RULE]; // if there aren't any interactive components, then we don't need to add special opacity rules diff --git a/src/specBuilder/sunburst/sunburstDataModificationUtils.test.ts b/src/specBuilder/sunburst/sunburstDataModificationUtils.test.ts new file mode 100644 index 000000000..2b5a8c2fa --- /dev/null +++ b/src/specBuilder/sunburst/sunburstDataModificationUtils.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { Datum } from 'vega'; + +import { createLeafValues } from './sunburstDataModificationUtils'; + +describe('createLeafValues', () => { + test('should correctly calculate childSum and leafValue', () => { + const data: Datum[] = [ + { id: '1', metric: 10 }, + { id: '2', parent: '1', metric: 3 }, + { id: '3', parent: '1', metric: 2 }, + { id: '4', parent: '2', metric: 1 }, + ]; + + createLeafValues(data, 'id', 'parent', 'metric'); + + expect(data[0]).toEqual({ + id: '1', + metric: 10, + metric_childSum: 5, + metric_leafValue: 5, + }); + + expect(data[1]).toEqual({ + id: '2', + parent: '1', + metric: 3, + metric_childSum: 1, + metric_leafValue: 2, + }); + + expect(data[2]).toEqual({ + id: '3', + parent: '1', + metric: 2, + metric_childSum: 0, + metric_leafValue: 2, + }); + + expect(data[3]).toEqual({ + id: '4', + parent: '2', + metric: 1, + metric_childSum: 0, + metric_leafValue: 1, + }); + }); + + test('should set leafValue to 0 if it is negative', () => { + const data: Datum[] = [ + { id: '1', parent: null, metric: 2 }, + { id: '2', parent: '1', metric: 3 }, + ]; + + createLeafValues(data, 'id', 'parent', 'metric'); + + expect(data[0]).toEqual({ + id: '1', + parent: null, + metric: 2, + metric_childSum: 3, + metric_leafValue: 0, + }); + + expect(data[1]).toEqual({ + id: '2', + parent: '1', + metric: 3, + metric_childSum: 0, + metric_leafValue: 3, + }); + }); +}); diff --git a/src/specBuilder/sunburst/sunburstDataModificationUtils.ts b/src/specBuilder/sunburst/sunburstDataModificationUtils.ts new file mode 100644 index 000000000..8b94fa493 --- /dev/null +++ b/src/specBuilder/sunburst/sunburstDataModificationUtils.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { Datum } from 'vega'; + +export const createLeafValues = (data: Datum[], id: string, parentKey: string, metric: string) => { + data.forEach((element) => { + element[`${metric}_childSum`] = data + .filter((e) => e[parentKey] === element[id]) + .reduce((acc, e) => acc + e[metric], 0); + }); + data.forEach((element) => { + element[`${metric}_leafValue`] = element[metric] - element[`${metric}_childSum`]; + if (element[`${metric}_leafValue`] < 0) { + element[`${metric}_leafValue`] = 0; + } + }); +}; diff --git a/src/specBuilder/sunburst/sunburstMarkUtils.test.ts b/src/specBuilder/sunburst/sunburstMarkUtils.test.ts new file mode 100644 index 000000000..56fac8afa --- /dev/null +++ b/src/specBuilder/sunburst/sunburstMarkUtils.test.ts @@ -0,0 +1,185 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { createElement } from 'react'; + +import { TABLE } from '@constants'; +import { ChartTooltip } from '@rsc'; +import { ArcMark } from 'vega'; + +import { getArcMark, getTextMark } from './sunburstMarkUtils'; +import { defaultSunburstProps } from './sunburstTestUtils'; + +describe('getArcMark', () => { + test('should return a valid ArcMark object', () => { + const expectedArcMark: ArcMark = { + type: 'arc', + name: defaultSunburstProps.name, + from: { data: TABLE }, + encode: { + enter: { + x: { signal: 'width / 2' }, + y: { signal: 'height / 2' }, + fill: { + scale: 'color', + field: 'segment', + }, + fillOpacity: { scale: 'opacity', field: 'depth' }, + tooltip: undefined, + }, + update: { + startAngle: { field: 'a0' }, + endAngle: { field: 'a1' }, + innerRadius: { field: 'r0' }, + outerRadius: { field: 'r1' }, + stroke: { value: 'white' }, + strokeWidth: { value: 0.5 }, + zindex: { value: 0 }, + opacity: undefined, + }, + }, + }; + + const arcMark = getArcMark(defaultSunburstProps); + expect(arcMark).toEqual(expectedArcMark); + }); + + test('should include tooltip and update opacity when proper props are passed', () => { + const expectedArcMark: ArcMark = { + type: 'arc', + name: defaultSunburstProps.name, + from: { data: TABLE }, + encode: { + enter: { + x: { signal: 'width / 2' }, + y: { signal: 'height / 2' }, + fill: { + scale: 'color', + field: 'segment', + }, + fillOpacity: { scale: 'opacity', field: 'depth' }, + tooltip: { + signal: "merge(datum, {'rscComponentName': 'testName'})", + }, + }, + update: { + startAngle: { field: 'a0' }, + endAngle: { field: 'a1' }, + innerRadius: { field: 'r0' }, + outerRadius: { field: 'r1' }, + stroke: { value: 'white' }, + strokeWidth: { value: 0.5 }, + zindex: { value: 0 }, + opacity: [ + { + test: 'isArray(highlightedItem) && length(highlightedItem) > 0 && indexof(highlightedItem, datum.rscMarkId) === -1', + value: 0.2, + }, + { + test: '!isArray(highlightedItem) && isValid(highlightedItem) && highlightedItem !== datum.rscMarkId', + value: 0.2, + }, + { + value: 1, + }, + ], + }, + }, + }; + + const arcMark = getArcMark({ + ...defaultSunburstProps, + children: [createElement(ChartTooltip)], + muteElementsOnHover: true, + }); + expect(arcMark).toEqual(expectedArcMark); + }); +}); + +describe('getTextMark', () => { + test('should return a valid TextMark object', () => { + const expectedTextMark = { + type: 'text', + name: `${defaultSunburstProps.name}_text`, + from: { data: TABLE }, + encode: { + enter: { + text: { field: defaultSunburstProps.metric }, + fontSize: { value: 9 }, + baseline: { value: 'middle' }, + align: { value: 'center' }, + tooltip: undefined, + }, + update: { + x: { signal: 'width / 2' }, + y: { signal: 'height / 2' }, + radius: { signal: "(datum['r0'] == 0 ? 0 : datum['r0'] + datum['r1']) / 2" }, + theta: { signal: "(datum['a0'] + datum['a1']) / 2" }, + angle: { + signal: "datum['r0'] == 0 ? 0 : ((datum['a0'] + datum['a1']) / 2) * 180 / PI + (inrange(((datum['a0'] + datum['a1']) / 2) % (2 * PI), [0, PI]) ? 270 : 90)", + }, + opacity: undefined, + }, + }, + }; + + const textMark = getTextMark(defaultSunburstProps); + expect(textMark).toEqual(expectedTextMark); + }); + + test('should include tooltip and update opacity when proper props are passed', () => { + const expectedTextMark = { + type: 'text', + name: `${defaultSunburstProps.name}_text`, + from: { data: TABLE }, + encode: { + enter: { + text: { field: defaultSunburstProps.metric }, + fontSize: { value: 9 }, + baseline: { value: 'middle' }, + align: { value: 'center' }, + tooltip: { + signal: "merge(datum, {'rscComponentName': 'testName'})", + }, + }, + update: { + x: { signal: 'width / 2' }, + y: { signal: 'height / 2' }, + radius: { signal: "(datum['r0'] == 0 ? 0 : datum['r0'] + datum['r1']) / 2" }, + theta: { signal: "(datum['a0'] + datum['a1']) / 2" }, + angle: { + signal: "datum['r0'] == 0 ? 0 : ((datum['a0'] + datum['a1']) / 2) * 180 / PI + (inrange(((datum['a0'] + datum['a1']) / 2) % (2 * PI), [0, PI]) ? 270 : 90)", + }, + opacity: [ + { + test: 'isArray(highlightedItem) && length(highlightedItem) > 0 && indexof(highlightedItem, datum.rscMarkId) === -1', + value: 0.2, + }, + { + test: '!isArray(highlightedItem) && isValid(highlightedItem) && highlightedItem !== datum.rscMarkId', + value: 0.2, + }, + { + value: 1, + }, + ], + }, + }, + }; + + const textMark = getTextMark({ + ...defaultSunburstProps, + children: [createElement(ChartTooltip)], + muteElementsOnHover: true, + }); + expect(textMark).toEqual(expectedTextMark); + }); +}); diff --git a/src/specBuilder/sunburst/sunburstMarkUtils.ts b/src/specBuilder/sunburst/sunburstMarkUtils.ts new file mode 100644 index 000000000..63746a3c2 --- /dev/null +++ b/src/specBuilder/sunburst/sunburstMarkUtils.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { TABLE } from '@constants'; +import { getMarkOpacity, getTooltip } from '@specBuilder/marks/markUtils'; +import { ArcMark, TextMark } from 'vega'; + +import { SunburstSpecProps } from '../../types'; + +export const getArcMark = (props: SunburstSpecProps): ArcMark => { + const { name, children, segmentKey, muteElementsOnHover } = props; + return { + type: 'arc', + name, + from: { data: TABLE }, + encode: { + enter: { + x: { signal: 'width / 2' }, + y: { signal: 'height / 2' }, + fill: { + scale: 'color', + field: segmentKey, + }, + fillOpacity: { scale: 'opacity', field: 'depth' }, + tooltip: getTooltip(children, name), + }, + update: { + startAngle: { field: 'a0' }, + endAngle: { field: 'a1' }, + innerRadius: { field: 'r0' }, + outerRadius: { field: 'r1' }, + stroke: { value: 'white' }, + strokeWidth: { value: 0.5 }, + zindex: { value: 0 }, + opacity: muteElementsOnHover ? getMarkOpacity(props) : undefined, + }, + }, + }; +}; + +export const getTextMark = (props: SunburstSpecProps): TextMark => { + const { metric, children, name, muteElementsOnHover } = props; + return { + type: 'text', + name: `${name}_text`, + from: { data: TABLE }, + encode: { + enter: { + text: { field: metric }, + fontSize: { value: 9 }, + baseline: { value: 'middle' }, + align: { value: 'center' }, + tooltip: getTooltip(children, name), + }, + update: { + x: { signal: 'width / 2' }, + y: { signal: 'height / 2' }, + radius: { signal: "(datum['r0'] == 0 ? 0 : datum['r0'] + datum['r1']) / 2" }, + theta: { signal: "(datum['a0'] + datum['a1']) / 2" }, + angle: { + signal: "datum['r0'] == 0 ? 0 : ((datum['a0'] + datum['a1']) / 2) * 180 / PI + (inrange(((datum['a0'] + datum['a1']) / 2) % (2 * PI), [0, PI]) ? 270 : 90)", + }, + opacity: muteElementsOnHover ? getMarkOpacity(props) : undefined, + }, + }, + }; +}; diff --git a/src/specBuilder/sunburst/sunburstSpecBuilder.test.ts b/src/specBuilder/sunburst/sunburstSpecBuilder.test.ts new file mode 100644 index 000000000..dfa6e3a7e --- /dev/null +++ b/src/specBuilder/sunburst/sunburstSpecBuilder.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { createElement } from 'react'; + +import { COLOR_SCALE, HIGHLIGHTED_ITEM, OPACITY_SCALE, TABLE } from '@constants'; +import { ChartTooltip } from '@rsc'; +import { defaultSignals } from '@specBuilder/specTestUtils'; +import { initializeSpec } from '@specBuilder/specUtils'; + +import { addData, addMarks, addScales, addSignals, addSunburst } from './sunburstSpecBuilder'; +import { defaultSunburstProps } from './sunburstTestUtils'; + +describe('sunburstSpecBuilder', () => { + test('should return a spec', () => { + const spec = addSunburst(initializeSpec(), defaultSunburstProps); + expect(spec).toHaveProperty('data'); + expect(spec).toHaveProperty('scales'); + expect(spec).toHaveProperty('marks'); + expect(spec).toHaveProperty('signals'); + }); + + describe('addData', () => { + test('should add data tranforms correctly', () => { + const data = addData(initializeSpec().data ?? [], { ...defaultSunburstProps }); + + expect(data).toHaveLength(2); + expect(data[0].transform).toHaveLength(3); + expect(data[0].transform?.[0].type).toBe('identifier'); //this is added to all specs + expect(data[0].transform?.[1].type).toBe('stratify'); + expect(data[0].transform?.[2].type).toBe('partition'); + }); + + test('should add data transform even if there was no transform array yet', () => { + const data = addData([{ name: TABLE }], { ...defaultSunburstProps }); + expect(data).toHaveLength(1); + expect(data[0].transform).toHaveLength(2); + expect(data[0].transform?.[0].type).toBe('stratify'); + expect(data[0].transform?.[1].type).toBe('partition'); + }); + }); + + describe('addScales', () => { + test('should add scales correctly', () => { + const scales = addScales(initializeSpec().scales ?? [], defaultSunburstProps); + expect(scales).toHaveLength(2); + expect(scales[0]).toHaveProperty('name', OPACITY_SCALE); + expect(scales[0].domain).toHaveProperty('fields', ['depth']); + expect(scales[1]).toHaveProperty('name', COLOR_SCALE); + expect(scales[1].domain).toHaveProperty('fields', [defaultSunburstProps.segmentKey]); + }); + }); + + describe('addMarks', () => { + //more tests are specified in the sunburstMarkUtils.test.ts + test('should add arc marks correctly', () => { + const marks = addMarks(initializeSpec().marks ?? [], defaultSunburstProps); + expect(marks).toHaveLength(2); + expect(marks[0]).toHaveProperty('type', 'arc'); + expect(marks[1]).toHaveProperty('type', 'text'); + }); + }); + + describe('addSignals', () => { + test('adds no signals if no interactive children', () => { + const signals = addSignals(initializeSpec().signals ?? [], defaultSunburstProps); + expect(signals).toHaveLength(0); + }); + + test('should add hover events when tooltip is present', () => { + const signals = addSignals(defaultSignals, { + ...defaultSunburstProps, + children: [createElement(ChartTooltip)], + }); + expect(signals).toHaveLength(defaultSignals.length); + expect(signals[0]).toHaveProperty('name', HIGHLIGHTED_ITEM); + expect(signals[0].on).toHaveLength(2); + expect(signals[0].on?.[0]).toHaveProperty('events', '@testName:mouseover'); + expect(signals[0].on?.[1]).toHaveProperty('events', '@testName:mouseout'); + }); + }); +}); diff --git a/src/specBuilder/sunburst/sunburstSpecBuilder.ts b/src/specBuilder/sunburst/sunburstSpecBuilder.ts new file mode 100644 index 000000000..b6a476c1d --- /dev/null +++ b/src/specBuilder/sunburst/sunburstSpecBuilder.ts @@ -0,0 +1,108 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { COLOR_SCALE, DEFAULT_COLOR, DEFAULT_COLOR_SCHEME, DEFAULT_METRIC, OPACITY_SCALE, TABLE } from '@constants'; +import { getTooltipProps, hasInteractiveChildren } from '@specBuilder/marks/markUtils'; +import { addFieldToFacetScaleDomain } from '@specBuilder/scale/scaleSpecBuilder'; +import { addHighlightedItemSignalEvents } from '@specBuilder/signal/signalSpecBuilder'; +import { sanitizeMarkChildren, toCamelCase } from '@utils'; +import { produce } from 'immer'; +import { Data, Mark, PartitionTransform, Scale, Signal, Spec, StratifyTransform } from 'vega'; + +import { ColorScheme, HighlightedItem, SunburstProps, SunburstSpecProps } from '../../types'; +import { getArcMark, getTextMark } from './sunburstMarkUtils'; + +export const addSunburst = produce< + Spec, + [SunburstProps & { colorScheme?: ColorScheme; highlightedItem?: HighlightedItem; index?: number; idKey: string }] +>( + ( + spec, + { + children, + color = DEFAULT_COLOR, + colorScheme = DEFAULT_COLOR_SCHEME, + index = 0, + metric = DEFAULT_METRIC, + name, + id = 'id', + parentKey = 'parent', + segmentKey = 'segment', + muteElementsOnHover = false, + ...props + } + ) => { + // put props back together now that all defaults are set + const sunburstProps: SunburstSpecProps = { + children: sanitizeMarkChildren(children), + color, + colorScheme, + index, + markType: 'sunburst', + metric, + id, + parentKey, + segmentKey, + name: toCamelCase(name ?? `sunburst${index}`), + muteElementsOnHover, + ...props, + }; + + spec.data = addData(spec.data ?? [], sunburstProps); + spec.scales = addScales(spec.scales ?? [], sunburstProps); + spec.marks = addMarks(spec.marks ?? [], sunburstProps); + spec.signals = addSignals(spec.signals ?? [], sunburstProps); + } +); + +export const addData = produce((data, props) => { + const tableIndex = data.findIndex((d) => d.name === TABLE); + + //set up transforms + data[tableIndex].transform = data[tableIndex].transform ?? []; + data[tableIndex].transform?.push(...getSunburstDataTransforms(props)); +}); + +const getSunburstDataTransforms = ({ + id, + parentKey, + metric, +}: SunburstSpecProps): (StratifyTransform | PartitionTransform)[] => [ + { + type: 'stratify', + key: id, + parentKey: parentKey, + }, + { + type: 'partition', + field: `${metric}_leafValue`, + sort: { field: metric }, + size: [{ signal: '2 * PI' }, { signal: 'width / 2' }], + as: ['a0', 'r0', 'a1', 'r1', 'depth', 'children'], + }, +]; + +export const addScales = produce((scales, props) => { + const { segmentKey } = props; + addFieldToFacetScaleDomain(scales, OPACITY_SCALE, 'depth'); + addFieldToFacetScaleDomain(scales, COLOR_SCALE, segmentKey); +}); + +export const addMarks = produce((marks, props) => { + marks.push(getArcMark(props)); + marks.push(getTextMark(props)); +}); + +export const addSignals = produce((signals, props) => { + const { children, idKey, name } = props; + if (!hasInteractiveChildren(children)) return; + addHighlightedItemSignalEvents(signals, name, idKey, 1, getTooltipProps(children)?.excludeDataKeys); +}); diff --git a/src/specBuilder/sunburst/sunburstTestUtils.ts b/src/specBuilder/sunburst/sunburstTestUtils.ts new file mode 100644 index 000000000..1e465a8f7 --- /dev/null +++ b/src/specBuilder/sunburst/sunburstTestUtils.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { MARK_ID } from '@constants'; + +import { SunburstSpecProps } from '../../types'; + +export const defaultSunburstProps: SunburstSpecProps = { + children: [], + color: 'testColor', + colorScheme: 'light', + idKey: MARK_ID, + index: 0, + markType: 'sunburst', + metric: 'value', + parentKey: 'parent', + id: 'id', + segmentKey: 'segment', + muteElementsOnHover: false, + name: 'testName', +}; diff --git a/src/stories/components/Sunburst/Sunburst.story.tsx b/src/stories/components/Sunburst/Sunburst.story.tsx new file mode 100644 index 000000000..c2db84cfe --- /dev/null +++ b/src/stories/components/Sunburst/Sunburst.story.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { ReactElement } from 'react'; + +import useChartProps from '@hooks/useChartProps'; +import { Chart, ChartPopover, ChartProps, ChartTooltip, Datum, Legend, SunburstProps } from '@rsc'; +import { Sunburst } from '@rsc/alpha'; +import { StoryFn } from '@storybook/react'; +import { bindWithProps } from '@test-utils'; + +import { Content } from '@adobe/react-spectrum'; + +import { basicSunburstData, simpleSunburstData } from './data'; + +export default { + title: 'RSC/Sunburst', + component: Sunburst, +}; + +const defaultChartProps: ChartProps = { + data: basicSunburstData, + width: 350, + height: 350, +}; + +const smallChartProps: ChartProps = { + data: simpleSunburstData, + width: 350, + height: 350, +}; + +const SimpleSunburstStory: StoryFn = (args): ReactElement => { + const { width, height, ...sunburstProps } = args; + const chartProps = useChartProps({ ...smallChartProps, width: width ?? 600, height: height ?? 600 }); + return ( + + + + ); +}; + +const SunburstStory: StoryFn = (args): ReactElement => { + const { width, height, ...sunburstProps } = args; + const chartProps = useChartProps({ ...defaultChartProps, width: width ?? 600, height: height ?? 600 }); + return ( + + + + ); +}; + +const SunburstLegendStory: StoryFn = (args): ReactElement => { + const chartProps = useChartProps({ ...defaultChartProps, height: 400, width: 600 }); + return ( + + + + + ); +}; + +const dialogContent = (datum: Datum) => { + return ( + +
Browser: {datum.name}
+
Users: {datum.value}
+ {datum.segment &&
Details: {datum.segment}
} +
+ ); +}; + +const interactiveChildren = [ + {dialogContent}, + + {dialogContent} + , +]; + +const Basic = bindWithProps(SimpleSunburstStory); +Basic.args = { + metric: 'value', + parentKey: 'parent', + id: 'id', + segmentKey: 'segment', +}; + +const Complex = bindWithProps(SunburstStory); +Complex.args = { + metric: 'value', + parentKey: 'parent', + id: 'id', + segmentKey: 'segment', +}; + +const WithPopovers = bindWithProps(SunburstStory); +WithPopovers.args = { + metric: 'value', + parentKey: 'parent', + id: 'id', + segmentKey: 'segment', + children: interactiveChildren, +}; + +const WithLegend = bindWithProps(SunburstLegendStory); +WithLegend.args = { + metric: 'value', + parentKey: 'parent', + id: 'id', + segmentKey: 'segment', + children: interactiveChildren, +}; + +const WithLegendAndMuting = bindWithProps(SunburstLegendStory); +WithLegendAndMuting.args = { + metric: 'value', + parentKey: 'parent', + id: 'id', + segmentKey: 'segment', + muteElementsOnHover: true, + children: interactiveChildren, +}; + +export { Basic, Complex, WithPopovers, WithLegend, WithLegendAndMuting }; diff --git a/src/stories/components/Sunburst/data.ts b/src/stories/components/Sunburst/data.ts new file mode 100644 index 000000000..61d3b7ea2 --- /dev/null +++ b/src/stories/components/Sunburst/data.ts @@ -0,0 +1,272 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export const basicSunburstData = [ + { + id: 1, + name: 'All browsers', + value: 2235, + }, + { + id: 2, + name: 'Chrome', + parent: 1, + value: 800, + segment: 'Chrome', + }, + { + id: 3, + name: 'V 130', + parent: 2, + value: 200, + segment: 'Chrome', + }, + { + id: 4, + parent: 3, + name: 'V 130.1', + value: 30, + segment: 'Chrome', + }, + { + id: 5, + parent: 3, + name: 'V 130.2', + value: 100, + segment: 'Chrome', + }, + { + id: 6, + parent: 2, + name: 'V 131', + value: 100, + segment: 'Chrome', + }, + { + id: 7, + parent: 2, + name: 'V 132', + value: 500, + segment: 'Chrome', + }, + { + id: 8, + parent: 1, + name: 'Firefox', + value: 600, + segment: 'Firefox', + }, + { + id: 9, + parent: 8, + name: 'Alpha', + value: 100, + segment: 'Firefox', + }, + { + id: 10, + parent: 9, + name: 'Alpha 1', + value: 50, + segment: 'Firefox', + }, + { + id: 11, + parent: 9, + name: 'Alpha 2', + value: 50, + segment: 'Firefox', + }, + { + id: 12, + parent: 8, + name: 'Beta', + value: 200, + segment: 'Firefox', + }, + { + id: 13, + parent: 12, + name: 'Beta 1', + value: 40, + segment: 'Firefox', + }, + { + id: 14, + parent: 12, + name: 'Beta 2', + value: 100, + segment: 'Firefox', + }, + { + id: 15, + parent: 8, + name: 'Prod', + value: 300, + segment: 'Firefox', + }, + { + id: 16, + parent: 15, + name: 'Prod 1', + value: 100, + segment: 'Firefox', + }, + { + id: 17, + parent: 15, + name: 'Prod 2', + value: 150, + segment: 'Firefox', + }, + { + id: 18, + parent: 1, + name: 'Safari', + value: 150, + segment: 'Safari', + }, + { + id: 19, + parent: 18, + name: '12.4.64', + value: 20, + segment: 'Safari', + }, + { + id: 20, + parent: 18, + name: '12.4.65', + value: 50, + segment: 'Safari', + }, + { + id: 21, + parent: 18, + name: '12.5.0', + value: 60, + segment: 'Safari', + }, + { + id: 22, + parent: 1, + name: 'Edge', + value: 685, + segment: 'Edge', + }, + { + id: 23, + parent: 22, + name: '1', + value: 200, + segment: 'Edge', + }, + { + id: 24, + parent: 22, + name: '2', + value: 200, + segment: 'Edge', + }, + { + id: 25, + parent: 22, + name: '3', + value: 200, + segment: 'Edge', + }, + { + id: 26, + parent: 22, + name: '4', + value: 85, + segment: 'Edge', + }, + { + id: 27, + parent: 26, + name: '4.1', + value: 85, + segment: 'Edge', + }, + { + id: 28, + parent: 27, + name: '4.1.1', + value: 30, + segment: 'Edge', + }, + { + id: 29, + parent: 27, + name: '4.1.2', + value: 30, + segment: 'Edge', + }, + { + id: 30, + parent: 25, + name: '3.0.1', + value: 30, + segment: 'Edge', + }, +]; + +export const simpleSunburstData = [ + { + id: 1, + value: 100, + name: 'root', + }, + { + id: 2, + parent: 1, + value: 40, + name: 'A', + segment: 'A', + }, + { + id: 3, + parent: 1, + value: 60, + name: 'B', + segment: 'B', + }, + { + id: 4, + parent: 2, + value: 30, + name: 'A 1', + segment: 'A', + }, + { + id: 5, + parent: 3, + value: 10, + name: 'B 1', + segment: 'B', + }, + { + id: 6, + parent: 3, + value: 20, + name: 'B 2', + segment: 'B', + }, + { + id: 7, + parent: 5, + value: 10, + name: 'B 1 ^', + segment: 'B', + }, +]; diff --git a/src/types/Chart.ts b/src/types/Chart.ts index 02cb50c90..9ad1b6b4b 100644 --- a/src/types/Chart.ts +++ b/src/types/Chart.ts @@ -32,6 +32,7 @@ export type ChartElement = ReactElement>; export type ChartTooltipElement = ReactElement>; export type DonutElement = ReactElement>; +export type SunburstElement = ReactElement>; export type DonutSummaryElement = ReactElement>; export type LegendElement = ReactElement>; export type LineElement = ReactElement>; @@ -242,6 +243,20 @@ export interface DonutSummaryProps { label?: string; } +export interface SunburstProps extends MarkProps { + /** key for a data element */ + id: string; + + /** identifies the key of this elements parent, if any parent exists */ + parentKey: string; + + /** identifies which segment each element is part of */ + segmentKey: string; + + /** determines if other elements are muted when hovering a given element */ + muteElementsOnHover?: boolean; +} + export interface SegmentLabelProps { /** Sets the key in the data that has the segment label. Defaults to the `color` key set on the `Donut` is undefined. */ labelKey?: string; @@ -872,6 +887,8 @@ export type ChartChildElement = | LineElement | ScatterElement | TitleElement + | DonutElement + | SunburstElement | ComboElement; export type MarkChildElement = | AnnotationElement diff --git a/src/types/specBuilderTypes.ts b/src/types/specBuilderTypes.ts index bdf8bb556..9dd1cbc43 100644 --- a/src/types/specBuilderTypes.ts +++ b/src/types/specBuilderTypes.ts @@ -39,6 +39,7 @@ import { ScatterPathProps, ScatterProps, SegmentLabelProps, + SunburstProps, TrendlineAnnotationProps, TrendlineChildElement, TrendlineProps, @@ -150,6 +151,24 @@ export interface DonutSpecProps extends PartiallyRequired { + children: MarkChildElement[]; + colorScheme: ColorScheme; + highlightedItem?: HighlightedItem; + idKey: string; + index: number; + markType: 'sunburst'; +} + type DonutSummaryPropsWithDefaults = 'numberFormat'; export interface DonutSummarySpecProps extends PartiallyRequired { diff --git a/src/utils/utils.ts b/src/utils/utils.ts index f6636281c..91ec58fba 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -30,7 +30,7 @@ import { Trendline, TrendlineAnnotation, } from '@rsc'; -import { Combo } from '@rsc/alpha'; +import { Combo, Sunburst } from '@rsc/alpha'; import { BigNumber, Donut, DonutSummary, SegmentLabel } from '@rsc/rc'; import { View } from 'vega'; @@ -54,6 +54,7 @@ import { MarkChildElement, RscElement, ScatterElement, + SunburstElement, TrendlineElement, } from '../types'; @@ -64,6 +65,7 @@ type ElementCounts = { axisAnnotation: number; bar: number; donut: number; + sunburst: number; legend: number; line: number; scatter: number; @@ -97,6 +99,7 @@ export const sanitizeRscChartChildren = (children: unknown): ChartChildElement[] Axis.displayName, Bar.displayName, Donut.displayName, + Sunburst.displayName, Legend.displayName, Line.displayName, Scatter.displayName, @@ -339,6 +342,9 @@ const getElementName = (element: unknown, elementCounts: ElementCounts) => { case Donut.displayName: elementCounts.donut++; return getComponentName(element as DonutElement, `donut${elementCounts.donut}`); + case Sunburst.displayName: + elementCounts.sunburst++; + return getComponentName(element as SunburstElement, `sunburst${elementCounts.sunburst}`); case Legend.displayName: elementCounts.legend++; return getComponentName(element as LegendElement, `legend${elementCounts.legend}`); @@ -377,6 +383,7 @@ const initElementCounts = (): ElementCounts => ({ axisAnnotation: -1, bar: -1, donut: -1, + sunburst: -1, legend: -1, line: -1, scatter: -1,