diff --git a/package.json b/package.json index 4696cd8e0..e953461b1 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "chalk": "4.1.2", "clean-webpack-plugin": "^4.0.0", "concurrently": "^8.0.0", - "cross-env": "^7.0.3", + "cross-env": "^10.1.0", "css-loader": "^7.1.2", "eslint": "^8.29.0", "eslint-config-prettier": "^9.0.0", @@ -175,5 +175,6 @@ "React", "Spectrum", "Charts" - ] + ], + "dependencies": {} } diff --git a/packages/constants/constants.ts b/packages/constants/constants.ts index 804518ac8..bd52d76a9 100644 --- a/packages/constants/constants.ts +++ b/packages/constants/constants.ts @@ -26,6 +26,7 @@ export const DEFAULT_FONT_SIZE = 14; export const DEFAULT_FONT_COLOR = 'gray-800'; export const DEFAULT_GRANULARITY = 'day'; export const DEFAULT_HIDDEN_SERIES = []; +export const DEFAULT_LABEL = false; export const DEFAULT_LABEL_ALIGN = 'center'; export const DEFAULT_LABEL_FONT_WEIGHT = 'normal'; export const DEFAULT_LABEL_ORIENTATION = 'horizontal'; @@ -35,6 +36,10 @@ export const DEFAULT_LINE_WIDTHS = ['M']; export const DEFAULT_LINEAR_DIMENSION = 'x'; export const DEFAULT_LOCALE = 'en-US'; export const DEFAULT_METRIC = 'value'; +export const DEFAULT_MAX_ARC_VALUE = 100; +export const DEFAULT_MIN_ARC_VALUE = 0; +export const DEFAULT_NEEDLE = false; +export const DEFAULT_TARGET_LINE = false; export const DEFAULT_SCALE_TYPE = 'normal'; export const DEFAULT_SCALE_VALUE = 100; export const DEFAULT_SECONDARY_COLOR = 'subSeries'; diff --git a/packages/docs/docs/api/visualizations/Gauge.md b/packages/docs/docs/api/visualizations/Gauge.md new file mode 100644 index 000000000..9bcca8d66 --- /dev/null +++ b/packages/docs/docs/api/visualizations/Gauge.md @@ -0,0 +1,140 @@ +## ALPHA RELEASE + +Gauge is currently in alpha. This means that the component, behavior and API are all subject to change. + +``` +import { Chart, ChartProps } from '@adobe/react-spectrum-charts'; +import { Gauge, GaugeSummary, SegmentLabel } from '@adobe/react-spectrum-charts/rc'; +``` + +# Gauge +The `Gauge` component is used to display data in a dashboard gauge style. + +## Needle +The gauge can draw a mark needle for progression measurement and data tracking. Disabled by default. + +## Target Line +The target line shows a line representing a goal value for the metric. Disabled by default. + +## Performance Ranges +The gauge can draw `performance ranges` marks for a given series of target ranges, defining color for detailed data tracking. Disabled by default. + +## Dynamic Labeling with unit options +The guage label can be represented as a percentage or numeric value and resizes in relation to the needle being present. + +## Props + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
nametypedefaultdescription
metricnumber'value'The data that is used for the current value.
colorstring'series'The data that is used as the color to current value.
namestring'gauge0'Sets the name of the component.
graphLabelstring'graphLabel'The data that is used as the graph label.
showLabelbooleanfalseSets to show the label or not.
showsAsPercentbooleanfalseSets to show the current value as a percentage or not.
minArcValuenumber0Minimum value for the scale. This value must be greater than zero, and less than maxArcValue.
maxArcValuenumber100Maximum value for the scale. This value must be greater than zero, and greater than minArcValue.
currValnumber75The current value tracked and its progress in the gauge. Set to 75 out of 100 by default.
backgroundFillstring-The color of the background arc.
backgroundStrokestring-The color of the background stroke.
fillerColorSignalstring-The color of the filler color arc.
needlebooleanfalseThe needle mark for tracking progress towards a goal.
targetstring'target'The data that is used as the target.
targetLinebooleanfalseShows a line for target tracking.
performanceRanges-falseArray of performance ranges to be rendered as filled bands on the gauge.
showPerformanceRangesnumber0If true, show banded performance ranges instead of a colored filler arc.
diff --git a/packages/react-spectrum-charts/src/alpha/components/Gauge/Gauge.tsx b/packages/react-spectrum-charts/src/alpha/components/Gauge/Gauge.tsx new file mode 100644 index 000000000..000d09e32 --- /dev/null +++ b/packages/react-spectrum-charts/src/alpha/components/Gauge/Gauge.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2025 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_MAX_ARC_VALUE, + DEFAULT_MIN_ARC_VALUE, + DEFAULT_LABEL, + DEFAULT_NEEDLE, + DEFAULT_TARGET_LINE, +} from '@spectrum-charts/constants'; + +import { GaugeProps } from '../../../types'; + +const Gauge: FC = ({ + name = 'gauge0', + graphLabel = 'graphLabel', + showLabel = DEFAULT_LABEL, + showsAsPercent = false, + metric = 'currentAmount', + minArcValue = DEFAULT_MIN_ARC_VALUE, + maxArcValue = DEFAULT_MAX_ARC_VALUE, + needle = DEFAULT_NEEDLE, + targetLine = DEFAULT_TARGET_LINE, + target = 'target', + showPerformanceRanges = false, +}) => { + return null; +}; + +// displayName is used to validate the component type in the spec builder +Gauge.displayName = 'Gauge'; + +export { Gauge }; diff --git a/packages/react-spectrum-charts/src/alpha/components/Gauge/index.ts b/packages/react-spectrum-charts/src/alpha/components/Gauge/index.ts new file mode 100644 index 000000000..72f28c6a1 --- /dev/null +++ b/packages/react-spectrum-charts/src/alpha/components/Gauge/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2025 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 './Gauge'; diff --git a/packages/react-spectrum-charts/src/alpha/components/index.ts b/packages/react-spectrum-charts/src/alpha/components/index.ts index fedea5918..ea2dcf0e4 100644 --- a/packages/react-spectrum-charts/src/alpha/components/index.ts +++ b/packages/react-spectrum-charts/src/alpha/components/index.ts @@ -13,3 +13,4 @@ export * from './Bullet'; export * from './Combo'; export * from './Venn'; +export * from './Gauge/Gauge'; diff --git a/packages/react-spectrum-charts/src/rscToSbAdapter/childrenAdapter.ts b/packages/react-spectrum-charts/src/rscToSbAdapter/childrenAdapter.ts index 37283ebba..6a4b33744 100644 --- a/packages/react-spectrum-charts/src/rscToSbAdapter/childrenAdapter.ts +++ b/packages/react-spectrum-charts/src/rscToSbAdapter/childrenAdapter.ts @@ -18,6 +18,7 @@ import { ChartPopoverOptions, ChartTooltipOptions, DonutSummaryOptions, + GaugeOptions, LegendOptions, LineOptions, MarkOptions, @@ -30,7 +31,7 @@ import { TrendlineOptions, } from '@spectrum-charts/vega-spec-builder'; -import { Bullet, Combo, Venn } from '../alpha'; +import { Bullet, Combo, Gauge, Venn } from '../alpha'; import { Annotation } from '../components/Annotation'; import { Area } from '../components/Area'; import { Axis } from '../components/Axis'; @@ -158,6 +159,10 @@ export const childrenToOptions = ( marks.push({ ...child.props, markType: 'bullet' } as BulletOptions); break; + case Gauge.displayName: + marks.push({ ...child.props, markType: 'gauge' } as GaugeOptions); + break; + case ChartPopover.displayName: chartPopovers.push(getChartPopoverOptions(child.props as ChartPopoverProps)); break; diff --git a/packages/react-spectrum-charts/src/stories/components/Bullet/Bullet.story.tsx b/packages/react-spectrum-charts/src/stories/components/Bullet/Bullet.story.tsx index 350e9ac3f..81c4b7536 100644 --- a/packages/react-spectrum-charts/src/stories/components/Bullet/Bullet.story.tsx +++ b/packages/react-spectrum-charts/src/stories/components/Bullet/Bullet.story.tsx @@ -39,7 +39,7 @@ const BulletStory: StoryFn = const { width, height, ...bulletProps } = args; const chartProps = useChartProps({ ...defaultChartProps, width: width ?? 350, height: height ?? 350 }); return ( - + ); diff --git a/packages/react-spectrum-charts/src/stories/components/Gauge/Gauge.story.tsx b/packages/react-spectrum-charts/src/stories/components/Gauge/Gauge.story.tsx new file mode 100644 index 000000000..cc91755ed --- /dev/null +++ b/packages/react-spectrum-charts/src/stories/components/Gauge/Gauge.story.tsx @@ -0,0 +1,133 @@ +/* + * Copyright 2025 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 { StoryFn } from '@storybook/react'; + +import { Chart } from '../../../Chart'; +// Gauge chart component from alpha export +import { Gauge } from '../../../alpha'; +import { Title } from '../../../components'; +import useChartProps from '../../../hooks/useChartProps'; +import { bindWithProps } from '../../../test-utils'; +import { GaugeProps, ChartProps } from '../../../types'; +import { basicGaugeData, coloredPerformanceData } from './data'; + +export default { + title: 'RSC/Gauge (alpha)', + component: Gauge, +}; + +// Default chart properties +const defaultChartProps: ChartProps = { + data: basicGaugeData, + width: 500, + height: 600, +}; + +// Basic Gauge chart story +const GaugeStory: StoryFn = (args): ReactElement => { + const { width, height, ...gaugeProps } = args; + const chartProps = useChartProps({ ...defaultChartProps, width: width ?? 500, height: height ?? 500 }); + return ( + + + + ); +}; + +// Gauge with Title +const GaugeTitleStory: StoryFn = (args): ReactElement => { + const chartProps = useChartProps({ ...defaultChartProps, width: 400 }); + return ( + + + <Gauge {...args} /> + </Chart> + ); +}; + +// Basic Gauge chart story. All the ones below it are variations of the Gauge chart. +const Basic = bindWithProps(GaugeStory); +Basic.args = { + metric: 'currentAmount', + color: 'blue-900', +}; + +const PerformanceRange = bindWithProps(GaugeStory); +PerformanceRange.args = { + metric: 'currentAmount', + color: 'red-900', + showPerformanceRanges: true, + performanceRanges: coloredPerformanceData, +}; + +const Empty = bindWithProps(GaugeStory); +Empty.args = { + metric: 'currentAmount-60', + color: 'fuchsia-900', +}; + +const Full = bindWithProps(GaugeStory); +Full.args = { + metric: 'currentAmount+40', + color: 'static-pruple-900', +}; + +const GaugeLabelNoNeedleNoPercent = bindWithProps(GaugeStory); +GaugeLabelNoNeedleNoPercent.args = { + metric: 'currentAmount', + color: 'indigo-1200', + showLabel: true, + needle: false, + showsAsPercent: false +}; + +const GaugeLabelNoNeedlePercent = bindWithProps(GaugeStory); +GaugeLabelNoNeedlePercent.args = { + metric: 'currentAmount', + color: 'celery-800', + maxArcValue: 151, + showLabel: true, + needle: false, + showsAsPercent: true +}; + +const GaugeLabelNeedleNoPercent = bindWithProps(GaugeStory); +GaugeLabelNeedleNoPercent.args = { + metric: 'currentAmount', + color: 'cyan-700', + showLabel: true, + needle: true, + showsAsPercent: false +}; + +const GaugeLabelNeedlePercent = bindWithProps(GaugeStory); +GaugeLabelNeedlePercent.args = { + metric: 'currentAmount', + color: 'magenta-1000', + maxArcValue: 151, + showLabel: true, + needle: true, + showsAsPercent: true +}; + +export { + Basic, + Empty, + Full, + PerformanceRange, + GaugeLabelNoNeedleNoPercent, + GaugeLabelNeedleNoPercent, + GaugeLabelNoNeedlePercent, + GaugeLabelNeedlePercent +}; diff --git a/packages/react-spectrum-charts/src/stories/components/Gauge/Gauge.test.tsx b/packages/react-spectrum-charts/src/stories/components/Gauge/Gauge.test.tsx new file mode 100644 index 000000000..f6fd8de0e --- /dev/null +++ b/packages/react-spectrum-charts/src/stories/components/Gauge/Gauge.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2025 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 { Gauge } from '../../../alpha'; +import { findChart, findMarksByGroupName, render } from '../../../test-utils'; +import { Basic } from './Gauge.story'; + +describe('Gauge', () => { + // Gauge is not a real React component. This test provides test coverage for sonarqube + test('Gauge pseudo element', () => { + render(<Gauge />); + }); + + test('Basic gauge renders properly', async () => { + render(<Basic {...Basic.args} />); + const chart = await findChart(); + expect(chart).toBeInTheDocument(); + + const backgroundArc = await findMarksByGroupName(chart, 'gauge0BackgroundArc'); + expect(backgroundArc).toBeDefined(); + + const fillerArc = await findMarksByGroupName(chart, 'gauge0FillerArc'); + expect(fillerArc).toBeDefined(); + + const startCap = await findMarksByGroupName(chart, 'gauge0StartCap'); + expect(startCap).toBeDefined(); + + const endCap = await findMarksByGroupName(chart, 'gauge0EndCap'); + expect(endCap).toBeDefined(); + + }); +}); diff --git a/packages/react-spectrum-charts/src/stories/components/Gauge/data.ts b/packages/react-spectrum-charts/src/stories/components/Gauge/data.ts new file mode 100644 index 000000000..edfacf019 --- /dev/null +++ b/packages/react-spectrum-charts/src/stories/components/Gauge/data.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2025 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 basicGaugeData = [ + { graphLabel: 'Customers', currentAmount: 60, target: 80 }, +]; + +export const coloredPerformanceData = [ + { bandEndPct: 0.55, fill: 'red-900' }, + { bandEndPct: 0.8, fill: 'yellow-400' }, + { bandEndPct: 1, fill: 'green-700' }, +]; diff --git a/packages/react-spectrum-charts/src/types/chart.types.ts b/packages/react-spectrum-charts/src/types/chart.types.ts index 2cd21f23c..69cb33b5d 100644 --- a/packages/react-spectrum-charts/src/types/chart.types.ts +++ b/packages/react-spectrum-charts/src/types/chart.types.ts @@ -36,6 +36,7 @@ import { ComboElement, DonutElement, DonutSummaryElement, + GaugeElement, LineElement, MetricRangeElement, ScatterElement, @@ -54,6 +55,7 @@ export type ChartChildElement = | BigNumberElement | DonutElement | ComboElement + | GaugeElement | LegendElement | LineElement | ScatterElement diff --git a/packages/react-spectrum-charts/src/types/marks/gauge.types.ts b/packages/react-spectrum-charts/src/types/marks/gauge.types.ts new file mode 100644 index 000000000..238c371c3 --- /dev/null +++ b/packages/react-spectrum-charts/src/types/marks/gauge.types.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2025 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 { JSXElementConstructor, ReactElement } from 'react'; + +import { GaugeOptions } from '@spectrum-charts/vega-spec-builder'; + +export interface GaugeProps extends Omit<GaugeOptions, 'markType'> {} + +export type GaugeElement = ReactElement<GaugeProps, JSXElementConstructor<GaugeProps>>; diff --git a/packages/react-spectrum-charts/src/types/marks/index.ts b/packages/react-spectrum-charts/src/types/marks/index.ts index 05e62d712..3b71c1ac2 100644 --- a/packages/react-spectrum-charts/src/types/marks/index.ts +++ b/packages/react-spectrum-charts/src/types/marks/index.ts @@ -16,6 +16,7 @@ export * from './bigNumber.types'; export * from './bullet.types'; export * from './combo.types'; export * from './donut.types'; +export * from './gauge.types'; export * from './line.types'; export * from './scatter.types'; export * from './venn.types'; diff --git a/packages/react-spectrum-charts/src/utils/utils.ts b/packages/react-spectrum-charts/src/utils/utils.ts index 712e1084e..ebafc9843 100644 --- a/packages/react-spectrum-charts/src/utils/utils.ts +++ b/packages/react-spectrum-charts/src/utils/utils.ts @@ -17,7 +17,7 @@ import { SELECTED_GROUP, SELECTED_ITEM, SELECTED_SERIES, SERIES_ID } from '@spec import { combineNames, toCamelCase } from '@spectrum-charts/utils'; import { Datum } from '@spectrum-charts/vega-spec-builder'; -import { Bullet, Combo, Venn } from '../alpha'; +import { Bullet, Combo, Gauge, Venn } from '../alpha'; import { Annotation, Area, @@ -56,6 +56,7 @@ import { ComboElement, DonutElement, DonutSummaryElement, + GaugeElement, LegendElement, LineElement, MetricRangeElement, @@ -102,6 +103,7 @@ type ElementCounts = { scatter: number; combo: number; bullet: number; + gauge: number; venn: number; }; @@ -134,6 +136,7 @@ export const sanitizeChildren = (children: unknown): (ChartChildElement | MarkCh AxisThumbnail.displayName, Bar.displayName, Bullet.displayName, + Gauge.displayName, ChartPopover.displayName, ChartTooltip.displayName, Combo.displayName, @@ -165,6 +168,7 @@ export const sanitizeRscChartChildren = (children: unknown): ChartChildElement[] Axis.displayName, Bar.displayName, Donut.displayName, + Gauge.displayName, Legend.displayName, Line.displayName, Scatter.displayName, @@ -409,6 +413,9 @@ const getElementName = (element: unknown, elementCounts: ElementCounts) => { case Bullet.displayName: elementCounts.bullet++; return getComponentName(element as BulletElement, `bullet${elementCounts.bullet}`); + case Gauge.displayName: + elementCounts.gauge++; + return getComponentName(element as GaugeElement, `gauge${elementCounts.gauge}`); case Legend.displayName: elementCounts.legend++; return getComponentName(element as LegendElement, `legend${elementCounts.legend}`); @@ -449,6 +456,7 @@ const initElementCounts = (): ElementCounts => ({ line: -1, scatter: -1, combo: -1, + gauge: -1, venn: -1, }); diff --git a/packages/vega-spec-builder/src/chartSpecBuilder.ts b/packages/vega-spec-builder/src/chartSpecBuilder.ts index a61db2215..928287b7c 100644 --- a/packages/vega-spec-builder/src/chartSpecBuilder.ts +++ b/packages/vega-spec-builder/src/chartSpecBuilder.ts @@ -41,6 +41,7 @@ import { addArea } from './area/areaSpecBuilder'; import { addAxis } from './axis/axisSpecBuilder'; import { addBar } from './bar/barSpecBuilder'; import { addBullet } from './bullet/bulletSpecBuilder'; +import { addGauge } from './gauge/gaugeSpecBuilder'; import { addCombo } from './combo/comboSpecBuilder'; import { getSeriesIdTransform } from './data/dataUtils'; import { addDonut } from './donut/donutSpecBuilder'; @@ -128,7 +129,8 @@ export function buildSpec({ spec.signals = getDefaultSignals(options); spec.scales = getDefaultScales(colors, colorScheme, lineTypes, lineWidths, opacities, symbolShapes, symbolSizes); - let { areaCount, barCount, bulletCount, comboCount, donutCount, lineCount, scatterCount, vennCount } = + // added gaugeCount below + let { areaCount, barCount, bulletCount, comboCount, donutCount, gaugeCount, lineCount, scatterCount, vennCount } = initializeComponentCounts(); const specOptions = { colorScheme, idKey, highlightedItem }; spec = [...marks].reduce((acc: ScSpec, mark) => { @@ -148,6 +150,9 @@ export function buildSpec({ case 'donut': donutCount++; return addDonut(acc, { ...mark, ...specOptions, index: donutCount }); + case 'gauge': + gaugeCount++; + return addGauge(acc, { ...mark, ...specOptions, index: gaugeCount }); case 'line': lineCount++; return addLine(acc, { ...mark, ...specOptions, index: lineCount }); @@ -217,6 +222,7 @@ const initializeComponentCounts = () => { comboCount: -1, donutCount: -1, bulletCount: -1, + gaugeCount: -1, lineCount: -1, scatterCount: -1, vennCount: -1, diff --git a/packages/vega-spec-builder/src/gauge/gaugeDataUtils.test.ts b/packages/vega-spec-builder/src/gauge/gaugeDataUtils.test.ts new file mode 100644 index 000000000..6fb00aaa4 --- /dev/null +++ b/packages/vega-spec-builder/src/gauge/gaugeDataUtils.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2025 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 { Data } from 'vega'; +import { getGaugeTableData } from './gaugeDataUtils'; + +describe('getGaugeTableData', () => { + it('Should create a new table data if it does not exist', () => { + const data: Data[] = []; + + const result = getGaugeTableData(data); + + expect(result.name).toBe('table'); + expect(result.values).toEqual([]); + + expect(data.length).toBe(1); + expect(data[0]).toEqual(result); + }); + + it('Should return the existing table data if it exists', () => { + const existingTableData: Data = { + name: 'table', + values: [4], + }; + const data: Data[] = [existingTableData]; + + const result = getGaugeTableData(data); + + expect(result).toEqual(existingTableData); + }); +}); diff --git a/packages/vega-spec-builder/src/gauge/gaugeDataUtils.ts b/packages/vega-spec-builder/src/gauge/gaugeDataUtils.ts new file mode 100644 index 000000000..8bc25995f --- /dev/null +++ b/packages/vega-spec-builder/src/gauge/gaugeDataUtils.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2025 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 { Data, ValuesData } from 'vega'; + +import { TABLE } from '@spectrum-charts/constants'; + +import { getTableData } from '../data/dataUtils'; + +/** + * Retrieves the gauge table data from the provided data array. + * If it doesn't exist, creates and pushes a new one. + * @param data The data array. + * @returns The gauge table data. + */ +export const getGaugeTableData = (data: Data[]): ValuesData => { + let tableData = getTableData(data); + if (!tableData) { + tableData = { + name: TABLE, + values: [], + }; + data.push(tableData); + } + return tableData; +}; + +/** + * Generates the necessary formula transforms for the gauge chart. + * It calculates the xPaddingForTarget and, if in flexible scale mode, adds the flexibleScaleValue. + * It also generates a color expression for the threshold bars if applicable. + * @param gaugeOptions The gauge spec properties. + * @returns An array of formula transforms. + */ + +/** + * Generates a Vega expression for the color of the gauge chart based on the provided thresholds. + * The expression checks the value of the metric field against the thresholds and assigns the appropriate color. + * @param defaultColor The default color to use if no thresholds are met. + * @returns A string representing the Vega expression for the color. + */ + diff --git a/packages/vega-spec-builder/src/gauge/gaugeMarkUtils.test.ts b/packages/vega-spec-builder/src/gauge/gaugeMarkUtils.test.ts new file mode 100644 index 000000000..19c0ed420 --- /dev/null +++ b/packages/vega-spec-builder/src/gauge/gaugeMarkUtils.test.ts @@ -0,0 +1,368 @@ +/* + * Copyright 2025 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 { GroupMark } from 'vega'; + +import { + addGaugeMarks, + getBackgroundArc, + getFillerArc, + getNeedle, + getPerformanceRangeMarks, + getBandGap1, + getBandGap2, + getNeedleHole, + getTargetLine, + getStartCap, + getEndCap, + getLabel, + getValueLabel, + } from './gaugeMarkUtils'; + +import { defaultGaugeOptions } from './gaugeTestUtils'; +import { spectrumColors } from '../../../themes'; +import { DEFAULT_PERFORMANCE_RANGES } from './gaugeSpecBuilder'; + + +// getGaugeMarks +describe('getGaugeMarks', () => { + test('Should return the correct marks object', () => { + const data = addGaugeMarks([], defaultGaugeOptions); + expect(data).toBeDefined(); + expect(Array.isArray(data)).toBe(true); + expect(data).toHaveLength(4); + expect(data[0].type).toBe('arc'); + expect(data[1].type).toBe('arc'); + }); +}); + +// getGaugeBackgroundArc +describe('getGaugeBackgroundArc', () => { + test('Should return the correct background arc mark object', () => { + const data = getBackgroundArc("backgroundTestName", spectrumColors['light']['blue-200'], spectrumColors['light']['blue-300']); + expect(data).toBeDefined(); + expect(data.encode?.enter).toBeDefined(); + + // Expect the correct amount of fields in the update object + expect(Object.keys(data.encode?.enter ?? {}).length).toBe(7); + }); +}); + +// getFillerArc +describe('getFillerArc', () => { + test('Should return the correct filler arc mark object', () => { + const data = getFillerArc("fillerTestName", spectrumColors['light']['magenta-900']); + expect(data).toBeDefined(); + + expect(data.encode?.update).toBeDefined(); + expect(Object.keys(data.encode?.update ?? {}).length).toBe(2); + + expect(data.encode?.enter).toBeDefined(); + expect(Object.keys(data.encode?.enter ?? {}).length).toBe(7) + }); +}); + +// getGaugeNeedle +describe('getGaugeNeedle', () => { + test('Should return the needle mark object', () => { + const data = getNeedle("needleTestName"); + expect(data).toBeDefined(); + expect(data.encode?.update).toBeDefined(); + expect(Object.keys(data.encode?.update ?? {}).length).toBe(4); + + expect(data.encode?.enter).toBeDefined(); + expect(Object.keys(data.encode?.enter ?? {}).length).toBe(2) + }); +}); + +// getDefaultPerformanceRanges +describe('DEFAULT_PERFORMANCE_RANGES', () => { + test('Should use the correct bandEndPct and fill colors for red, yellow, green', () => { + expect(DEFAULT_PERFORMANCE_RANGES).toBeDefined(); + expect(DEFAULT_PERFORMANCE_RANGES).toHaveLength(3); + + const [band1, band2, band3] = DEFAULT_PERFORMANCE_RANGES; + + // Band 1: red + expect(band1.bandEndPct).toBe(0.55); + expect(band1.fill).toBe(spectrumColors.light['red-900']); + + // Band 2: yellow + expect(band2.bandEndPct).toBe(0.8); + expect(band2.fill).toBe(spectrumColors.light['yellow-400']); + + // Band 3: green + expect(band3.bandEndPct).toBe(1); + expect(band3.fill).toBe(spectrumColors.light['green-700']); + }); +}); + +// getPerformanceRangeTests +describe('getPerformanceRangesTests', () => { + const performanceRanges = [ + { bandEndPct: 0.55, fill: 'red-900' }, + { bandEndPct: 0.8, fill: 'yellow-900' }, + { bandEndPct: 1, fill: 'green-700' }, + ]; + + test('Performance ranges enabled adds band arcs, gaps, and caps', () => { + const data = addGaugeMarks([], { + ...defaultGaugeOptions, + showPerformanceRanges: true, + performanceRanges, + }); + expect(data).toHaveLength(9); + expect(data.map(mark => mark.type)).toEqual([ + 'arc', + 'arc', + 'arc', + 'rule', + 'rule', + 'arc', + 'arc', + 'symbol', + 'symbol', + ]); + }); + + test('Performance ranges disabled keeps background, filler, and caps', () => { + const data = addGaugeMarks([], { + ...defaultGaugeOptions, + showPerformanceRanges: false }); + expect(data).toHaveLength(4); + expect(data.every(mark => mark.type === 'arc')).toBe(true); + }); +});describe('getPerformanceRangesTests', () => { + const performanceRanges = [ + { bandEndPct: 0.55, fill: 'red-900' }, + { bandEndPct: 0.8, fill: 'yellow-900' }, + { bandEndPct: 1, fill: 'green-700' }, + ]; + + test('Performance ranges enabled adds band arcs, gaps, and caps', () => { + const data = addGaugeMarks([], { + ...defaultGaugeOptions, + showPerformanceRanges: true, + performanceRanges, + }); + expect(data).toHaveLength(9); + expect(data.map(mark => mark.type)).toEqual([ + 'arc', + 'arc', + 'arc', + 'rule', + 'rule', + 'arc', + 'arc', + 'symbol', + 'symbol', + ]); + }); + + test('Performance ranges disabled keeps background, filler, and caps', () => { + const data = addGaugeMarks([], { + ...defaultGaugeOptions, + showPerformanceRanges: false }); + expect(data).toHaveLength(4); + expect(data.every(mark => mark.type === 'arc')).toBe(true); + }); +}); + + + +// getBandGap1 +describe('getGaugeBandGap1', () => { + test('Should return the band 1 gap mark object', () => { + const data = getBandGap1('bandGapTestName'); + expect(data).toBeDefined(); + + expect(data.encode?.enter).toBeDefined(); + expect(Object.keys(data.encode?.enter ?? {}).length).toBe(3); + + expect(data.encode?.update).toBeDefined(); + expect(Object.keys(data.encode?.update ?? {}).length).toBe(5); + }); +}); + + +// getBandGap2 +describe('getGaugeBandGap2', () => { + test('Should return the band 2 gap mark object', () => { + const data = getBandGap2('bandGapTestName'); + expect(data).toBeDefined(); + + expect(data.encode?.enter).toBeDefined(); + expect(Object.keys(data.encode?.enter ?? {}).length).toBe(3); + + expect(data.encode?.update).toBeDefined(); + expect(Object.keys(data.encode?.update ?? {}).length).toBe(5); + }); +}); + +// getNeedleHole +describe('getGaugeNeedleHole', () => { + test('Should return the needle hole mark object', () => { + const backgroundColorSignal = 'chartBackgroundColor'; + const data = getNeedleHole('needleTestName', backgroundColorSignal); + expect(data).toBeDefined(); + expect(data.encode?.enter).toBeDefined(); + expect(Object.keys(data.encode?.enter ?? {}).length).toBe(2); + + expect(data.encode?.enter?.x).toBeDefined(); + expect(data.encode?.enter?.y).toBeDefined(); + + expect(data.encode?.update).toBeDefined(); + expect(Object.keys(data.encode?.update ?? {}).length).toBe(4); + }); +}); + +// getTargetLine +describe('getGaugeTargetLine', () => { + test('Should return the target line mark object', () => { + const data = getTargetLine('targetTestName'); + expect(data).toBeDefined(); + + + expect(data.encode?.enter).toBeDefined(); + expect(Object.keys(data.encode?.enter ?? {}).length).toBe(3); + + + expect(data.encode?.update).toBeDefined(); + expect(Object.keys(data.encode?.update ?? {}).length).toBe(5); + }); +}); + +// getStartCap +describe('getGaugeStartCap', () => { + test('Should return the start cap mark object', () => { + const data = getStartCap('startCapTestName', 'fillerColorToCurrVal', 'chartBackgroundColor'); + expect(data).toBeDefined(); + + expect(data.encode?.enter).toBeDefined(); + expect(Object.keys(data.encode?.enter ?? {}).length).toBe(8); + + expect(data.encode?.update).toBeDefined(); + expect(Object.keys(data.encode?.update ?? {}).length).toBe(2); + }); +}); + + +// getEndCap +describe('getGaugeEndCap', () => { + test('Should return the end cap mark object', () => { + const data = getEndCap('endCapTestName', 'fillerColorToCurrVal', 'chartBackgroundColor'); + expect(data).toBeDefined(); + + expect(data.encode?.enter).toBeDefined(); + expect(Object.keys(data.encode?.enter ?? {}).length).toBe(7); + + expect(data.encode?.update).toBeDefined(); + expect(Object.keys(data.encode?.update ?? {}).length).toBe(2); + }); +}); + +// getLabel +describe('getGaugeLabel', () => { + test('Should return the label mark object', () => { + const data = getLabel('labelTestName', 14, 20); + expect(data).toBeDefined(); + + expect(data.encode?.enter).toBeDefined(); + expect(Object.keys(data.encode?.enter ?? {}).length).toBe(4); + + expect(data.encode?.update).toBeDefined(); + expect(Object.keys(data.encode?.update ?? {}).length).toBe(3); + }); +}); + +// getValueLabel +describe('getGaugeValueLabel', () => { + test('Should return the value label mark object', () => { + const data = getValueLabel('valueLabelTestName', 16, 30); + expect(data).toBeDefined(); + + expect(data.encode?.enter).toBeDefined(); + expect(Object.keys(data.encode?.enter ?? {}).length).toBe(4); + + expect(data.encode?.update).toBeDefined(); + expect(Object.keys(data.encode?.update ?? {}).length).toBe(3); + }); +}); + +// getGaugeNeeldeAndLabelTests +describe('getGaugeNeedleAndLabelTests', () => { + test('Needle and Label Both True', () => { + const data = addGaugeMarks([], { + ...defaultGaugeOptions, + needle: true, + showLabel: true + }); + expect(data).toBeDefined(); + expect(Array.isArray(data)).toBe(true); + expect(data).toHaveLength(8); + + // Should have 4 arcs to start + expect(data.filter(m => m.type === 'arc')).toHaveLength(4); + // Needle should be present + expect(data.some(m => m.type === 'symbol' && (m.name ?? '').includes('Needle'))).toBe(true); + // Needle holde should be present + expect(data.some(m => m.type === 'symbol' && (m.name ?? '').includes('NeedleHole'))).toBe(true); + // Graph Label present + expect(data.some(m => m.type === 'text' && (m.name ?? '').includes('graphLabelText'))).toBe(true); + // Value Label present + expect(data.some(m => m.type === 'text' && (m.name ?? '').includes('graphLabelCurrentValueText'))).toBe(true); + }); + + test('Needle True and Label False', () => { + const data = addGaugeMarks([], { + ...defaultGaugeOptions, + needle: true, + showLabel: false + }); + expect(data).toBeDefined(); + expect(Array.isArray(data)).toBe(true); + expect(data).toHaveLength(6); + + // Still have 4 arcs to start + expect(data.filter(m => m.type === 'arc')).toHaveLength(4); + // Needle present + expect(data.some(m => m.type === 'symbol' && (m.name ?? '').includes('Needle'))).toBe(true); + // Needle holde should be present + expect(data.some(m => m.type === 'symbol' && (m.name ?? '').includes('NeedleHole'))).toBe(true); + // Graph Label present + expect(data.some(m => m.type === 'text' && (m.name ?? '').includes('graphLabelText'))).toBe(false); + // Value Label present + expect(data.some(m => m.type === 'text' && (m.name ?? '').includes('graphLabelCurrentValueText'))).toBe(false); + }); + + test('Needle False and Label True', () => { + const data = addGaugeMarks([], { + ...defaultGaugeOptions, + needle: false, + showLabel: true + }); + expect(data).toBeDefined(); + expect(Array.isArray(data)).toBe(true); + expect(data).toHaveLength(6); + + // Still have 4 arcs to start + expect(data.filter(m => m.type === 'arc')).toHaveLength(4); + // No needle mark + expect(data.some(m => m.type === 'symbol' && (m.name ?? '').includes('Needle'))).toBe(false); + // Needle holde should be present + expect(data.some(m => m.type === 'symbol' && (m.name ?? '').includes('NeedleHole'))).toBe(false); + // Graph Label present + expect(data.some(m => m.type === 'text' && (m.name ?? '').includes('graphLabelText'))).toBe(true); + // Value Label present + expect(data.some(m => m.type === 'text' && (m.name ?? '').includes('graphLabelCurrentValueText'))).toBe(true); + }); +}); + diff --git a/packages/vega-spec-builder/src/gauge/gaugeMarkUtils.ts b/packages/vega-spec-builder/src/gauge/gaugeMarkUtils.ts new file mode 100644 index 000000000..9c0e8b63f --- /dev/null +++ b/packages/vega-spec-builder/src/gauge/gaugeMarkUtils.ts @@ -0,0 +1,378 @@ +/* + * Copyright 2025 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 { produce } from 'immer'; +import { Mark } from 'vega'; + +import { DEFAULT_COLOR_SCHEME, BACKGROUND_COLOR } from '@spectrum-charts/constants'; + +import { GaugeSpecOptions, PerformanceRanges } from '../types'; +import { spectrumColors, getColorValue } from '@spectrum-charts/themes'; +import { defaultGaugeOptions } from './gaugeTestUtils'; + +export const addGaugeMarks = produce<Mark[], [GaugeSpecOptions]>((marks, opt) => { + const { + name, + colorScheme = DEFAULT_COLOR_SCHEME, + needle, + showLabel, + targetLine + } = opt; + const backgroundFill = spectrumColors[colorScheme]['gray-200']; + const backgroundStroke = spectrumColors[colorScheme]['gray-300']; + const fillerColorSignal = 'fillerColorToCurrVal'; + + // Performance ranges + if (opt.showPerformanceRanges) { + marks.push(...getPerformanceRangeMarks(name, opt.performanceRanges)); + marks.push(getBandGap1(name)); + marks.push(getBandGap2(name)); + // Add caps + marks.push(getStartCap(name, opt.performanceRanges[0].fill, opt.performanceRanges[0].fill)); + marks.push(getEndCap(name, opt.performanceRanges[2].fill, opt.performanceRanges[2].fill)); + } else { + // Background arc + marks.push(getBackgroundArc(name, backgroundFill)); + // Filler arc (fills to clampedValue) + marks.push(getFillerArc(name, fillerColorSignal)); + // Add caps + marks.push(getStartCap(name, fillerColorSignal, backgroundFill)); + marks.push(getEndCap(name, fillerColorSignal, backgroundFill)); + } + + // Needle to clampedValue +if ((needle || opt.showPerformanceRanges) && showLabel) { + marks.push(getNeedle(name)); + marks.push(getNeedleHole(name, BACKGROUND_COLOR)); + const yOffset = 140; + const fontSize = 28; + marks.push(getLabel(name, fontSize, yOffset)); + + const labelYOffset = 100; + const labelFontSize = 60; + marks.push(getValueLabel(name, labelFontSize, labelYOffset)); + } else if (needle || opt.showPerformanceRanges){ + marks.push(getNeedle(name)); + marks.push(getNeedleHole(name, BACKGROUND_COLOR)); + } else if (showLabel){ + const yOffset = 70; + const fontSize = 32; + marks.push(getLabel(name, fontSize, yOffset)); + + const labelYOffset = 0; + const labelFontSize = 84; + marks.push(getValueLabel(name, labelFontSize, labelYOffset)); + } + if (targetLine){ + marks.push(getTargetLine(name)); + } +}); + +export function getBackgroundArc(name: string, fill: string): Mark { + return { + name: `${name}BackgroundArc`, + description: 'Background Arc', + type: 'arc', + encode: { + enter: { + x: { signal: 'centerX' }, + y: { signal: 'centerY' }, + innerRadius: { signal: 'innerRadius' }, + outerRadius: { signal: 'outerRadius' }, + startAngle: { signal: 'startAngle' }, + endAngle: { signal: 'endAngle' }, + fill: { value: fill }, + } + } + }; +} + +export function getFillerArc(name: string, fillerColorSignal: string): Mark { + return { + name: `${name}FillerArc`, + description: 'Filler Arc', + type: 'arc', + encode: { + enter: { + x: { signal: 'centerX' }, + y: { signal: 'centerY' }, + innerRadius: { signal: 'innerRadius' }, + outerRadius: { signal: 'outerRadius' }, + startAngle: { signal: 'startAngle' }, + endAngle: { signal: 'endAngle' }, + fill: { signal: 'fillerColorToCurrVal' } + }, + update: { + endAngle: { signal: "scale('angleScale', clampedVal)" }, + stroke: { signal: `currVal > arcMinVal ? ${fillerColorSignal} : ""`} + } + } + }; +} + +export function getNeedle(name: string): Mark { + return { + name: `${name}Needle`, + description: 'Needle', + type: 'symbol', + encode: { + enter: { + x: { signal: "centerX" }, + y: { signal: "centerY" } + }, + update: { + shape: { + signal: + "'M -4 0 A 4 4 0 1 0 4 0 L 2 -' + needleLength + 'A 2 2 0 1 0 -2 -' + needleLength + ' ' + 'L -4 0 Z'" + }, + angle: { signal: "needleAngleDeg" }, + fill: { signal: "needleColor" }, + stroke: { signal: "needleColor" }, + } + } + }; +} + +export function getPerformanceRangeMarks( + name: string, + performanceRanges: PerformanceRanges[] +): Mark[] { + const [band1, band2, band3] = performanceRanges; + return [ + { + name: `${name}Band1Arc`, + type: 'arc', + encode: { + enter: { + x: { signal: 'centerX' }, + y: { signal: 'centerY' }, + innerRadius: { signal: 'innerRadius' }, + outerRadius: { signal: 'outerRadius' }, + startAngle: { signal: 'band1StartAngle' }, + endAngle: { signal: 'band1EndAngle' }, + fill: { value: band1.fill }, + }, + }, + }, + { + name: `${name}Band2Arc`, + type: 'arc', + encode: { + enter: { + x: { signal: 'centerX' }, + y: { signal: 'centerY' }, + innerRadius: { signal: 'innerRadius' }, + outerRadius: { signal: 'outerRadius' }, + startAngle: { signal: 'band2StartAngle' }, + endAngle: { signal: 'band2EndAngle' }, + fill: { value: band2.fill }, + }, + }, + }, + { + name: `${name}Band3Arc`, + type: 'arc', + encode: { + enter: { + x: { signal: 'centerX' }, + y: { signal: 'centerY' }, + innerRadius: { signal: 'innerRadius' }, + outerRadius: { signal: 'outerRadius' }, + startAngle: { signal: 'band3StartAngle' }, + endAngle: { signal: 'band3EndAngle' }, + fill: { value: band3.fill }, + }, + }, + }, + ]; +} + +export function getBandGap1(name: string): Mark { + + return { + name: `${name}Band1Gap`, + description: 'Band 1 Gap', + type: 'rule', + encode: { + enter: { + stroke: { signal: BACKGROUND_COLOR }, + strokeWidth: { value: 6 }, + strokeCap: { value: "round" } + }, + update: { + stroke: { signal: BACKGROUND_COLOR }, + x: { signal: "band1GapX" }, + y: { signal: "band1GapY" }, + x2: { signal: "band1GapX2" }, + y2: { signal: "band1GapY2" } + } + } + } +}; + +export function getBandGap2(name: string): Mark { + + return { + name: `${name}Band2Gap`, + description: 'Band 2 Gap', + type: 'rule', + encode: { + enter: { + stroke: { signal: BACKGROUND_COLOR }, + strokeWidth: { value: 6 }, + strokeCap: { value: "round" } + }, + update: { + stroke: { signal: BACKGROUND_COLOR }, + x: { signal: "band2GapX" }, + y: { signal: "band2GapY" }, + x2: { signal: "band2GapX2" }, + y2: { signal: "band2GapY2" } + } + } + } +}; + +export function getNeedleHole(name: string, backgroundColor): Mark { + return { + name: `${name}NeedleHole`, + description: 'Needle Hole', + type: 'symbol', + encode: { + enter: { + x: { signal: "centerX" }, + y: { signal: "centerY" } + }, + update: { + shape: { value: "circle" }, + size: { value: 750 }, + fill: { signal: backgroundColor }, + stroke: { signal: backgroundColor }, + } + } + }; +} + +export function getTargetLine(name: string): Mark { + return { + name: `${name}TargetLine`, + description: 'Target Line', + type: 'rule', + encode: { + enter: { + stroke: { signal: 'targetLineStroke' }, + strokeWidth: { value: 6 }, + strokeCap: { value: "round" } + }, + update: { + stroke: { signal: 'targetLineStroke' }, + x: { signal: "targetLineX" }, + y: { signal: "targetLineY" }, + x2: { signal: "targetLineX2" }, + y2: { signal: "targetLineY2" } + } + } + } +}; + +export function getStartCap(name: string, fillColor: string, backgroundColor: string): Mark { + const xOffset = 'centerX+(sin(startAngle)*((outerRadius+innerRadius)/2))' + const yOffset = 'centerY-(cos(startAngle)*((outerRadius+innerRadius)/2))' + return { + name: `${name}StartCap`, + description: `Start Cap`, + type: `arc`, + encode: { + enter: { + "x": { signal: xOffset }, + "y": { signal: yOffset }, + "innerRadius": { signal: '0' }, + "outerRadius": { signal: "(outerRadius - innerRadius) / 2 - 1" }, + "startAngle": { signal: "startAngle" }, + "endAngle": { signal: "startAngle-PI"}, + "stroke": { signal: fillColor }, + "strokeWidth": { signal: "2" }, + }, + update: { + "fill": {signal: `currVal <= arcMinVal ? ${backgroundColor} : ${fillColor}`}, + "stroke": { signal: `currVal <= arcMinVal ? ${backgroundColor} : ${fillColor}`} + } + } + } +} + +export function getEndCap(name: string, fillColor: string, backgroundColor: string): Mark { + const xOffset = 'centerX+(sin(endAngle)*((outerRadius+innerRadius)/2))' + const yOffset = 'centerY-(cos(endAngle)*((outerRadius+innerRadius)/2))' + return { + name: `${name}EndCap`, + description: `End Cap`, + type: `arc`, + encode: { + enter: { + "x": { signal: xOffset }, + "y": { signal: yOffset }, + "innerRadius": { signal: '0' }, + "outerRadius": { signal: "((outerRadius - innerRadius) / 2) - 1" }, + "startAngle": { signal: "endAngle" }, + "endAngle": { signal: "endAngle+PI"}, + "strokeWidth": { signal: "2" }, + }, + update: { + "fill": {signal: `currVal >= arcMaxVal ? ${fillColor} : ${backgroundColor}`}, + "stroke": { signal: `currVal >= arcMaxVal ? ${fillColor} : ${backgroundColor}` } + } + } + } +} + +export function getLabel(name: string, fontSize, yOffset): Mark { + const targetColor = getColorValue('gray-600', defaultGaugeOptions.colorScheme); + return { + name: `${name}graphLabelText`, + description: `graph label`, + type: `text`, + encode: { + enter: { + align: { value: "center" }, + baseline: { value: "middle" }, + fontSize: { value: fontSize }, + fill: { signal: 'labelTextColor' } + }, + update: { + x: { signal: "centerX" }, + y: { signal: `centerY + ${yOffset}` }, + text: { signal: "graphLabel" } + } + } + } +}; + +export function getValueLabel(name: string, fontSize, yOffset): Mark { + return { + name: `${name}graphLabelCurrentValueText`, + description: `graph current value label`, + type: `text`, + encode: { + enter: { + align: { value: "center" }, + baseline: { value: "middle" }, + fontSize: { value: fontSize }, + fill: { signal: 'valueTextColor' } + }, + update: { + x: { signal: "centerX" }, + y: { signal: `centerY + ${yOffset}` }, + text: { signal: "textSignal" } + } + } + } +}; \ No newline at end of file diff --git a/packages/vega-spec-builder/src/gauge/gaugeSpecBuilder.test.ts b/packages/vega-spec-builder/src/gauge/gaugeSpecBuilder.test.ts new file mode 100644 index 000000000..90ce04b79 --- /dev/null +++ b/packages/vega-spec-builder/src/gauge/gaugeSpecBuilder.test.ts @@ -0,0 +1,235 @@ +/* + * Copyright 2025 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 { GaugeOptions, ScSpec } from '../types'; +import { addGauge, addData, addScales, addSignals } from './gaugeSpecBuilder'; +import { defaultGaugeOptions } from './gaugeTestUtils'; + +import { getColorValue, spectrumColors } from '../../../themes'; + +import { DEFAULT_COLOR_SCHEME } from '@spectrum-charts/constants'; + +const byName = (signals: any[], name: string) => signals.find(s => s.name === name); + +describe('addGauge', () => { + let spec: ScSpec; + + beforeEach(() => { + spec = { data: [], marks: [], scales: [], usermeta: {} }; + }); + + test('should create a spec with gauge chart properties', () => { + const newSpec = addGauge(spec, defaultGaugeOptions); + + expect(newSpec).toBeDefined(); + expect(newSpec).toHaveProperty('data'); + expect(newSpec).toHaveProperty('marks'); + expect(newSpec).toHaveProperty('scales'); + expect(newSpec).toHaveProperty('signals'); + }); +}); + +describe('getGaugeScales', () => { + test('Should return the correct scale object', () => { + const data = addScales([], defaultGaugeOptions); + expect(data).toBeDefined(); + expect(data).toHaveLength(1); + expect('range' in data[0] && data[0].range).toBeTruthy(); + if ('range' in data[0] && data[0].range) { + expect(data[0].range[1].signal).toBe('endAngle'); + } + }); +}); + +describe('getGaugeSignals', () => { + test('Should return the correct signals object', () => { + const data = addSignals([], defaultGaugeOptions); + expect(data).toBeDefined(); + expect(data).toHaveLength(55); + }); +}); + +describe('getGaugeData', () => { + test('Should return the data object', () => { + const data = addData([], defaultGaugeOptions); + expect(data).toHaveLength(1); + }); +}); + +describe('addGauge (defaults & overrides for gaugeOptions)', () => { + let spec: ScSpec; + + beforeEach(() => { + spec = { data: [], marks: [], scales: [], usermeta: {} }; + }); + + test('uses defaults when no overrides are provided', () => { + const newSpec = addGauge(spec, defaultGaugeOptions); + + expect(newSpec).toBeDefined(); + expect(newSpec.signals).toBeDefined(); + + const signals = newSpec.signals as any[]; + + // min/max come from defaults in gaugeOptions + expect(byName(signals, 'arcMaxVal')?.value).toBe(100); + expect(byName(signals, 'arcMinVal')?.value).toBe(0); + + // default angles: -120° .. +120° + expect(byName(signals, 'startAngle')?.update).toBe('-PI * 2 / 3'); + expect(byName(signals, 'endAngle')?.update).toBe('PI * 2 / 3'); + + // default metric is 'currentAmount' + expect(byName(signals, 'currVal')?.update).toBe("data('table')[0].currentAmount"); + + // background fill from DEFAULT_COLOR_SCHEME + const scheme = DEFAULT_COLOR_SCHEME; + const expectedBgFill = spectrumColors[scheme]['gray-200']; + expect(byName(signals, 'backgroundfillColor')?.value).toBe(expectedBgFill); + + // fillerColorToCurrVal uses light + expect(byName(signals, 'fillerColorToCurrVal')?.value).toBe('light'); + + // geometry defaults + expect(byName(signals, 'radiusRef')?.update).toBe('min(width/2, height/2)'); + expect(byName(signals, 'outerRadius')?.update).toBe('radiusRef * 0.95'); + expect(byName(signals, 'innerRadius')?.update).toBe('outerRadius - (radiusRef * 0.25)'); + expect(byName(signals, 'centerX')?.update).toBe('width/2'); + expect(byName(signals, 'centerY')?.update).toBe('height/2 + outerRadius/2'); + + + // target value uses default target field from options + expect(byName(signals, 'target')?.update).toBe("data('table')[0].target"); + + // clamped value and angle mapping are wired as expressions + expect(byName(signals, 'clampedVal')?.update).toBe('min(max(arcMinVal, currVal), arcMaxVal)'); + expect(byName(signals, 'theta')?.update).toBe("scale('angleScale', clampedVal)"); + expect(byName(signals, 'needleAngleClampedVal')?.update).toBe("scale('angleScale', clampedVal)"); + expect(byName(signals, 'needleAngleDeg')?.update).toBe('needleAngleClampedVal * 180 / PI'); + expect(byName(signals, 'needleLength')?.update).toBe('30'); + + // label/value text colors from scheme + const expectedValueColor = getColorValue('gray-900', DEFAULT_COLOR_SCHEME); + const expectedLabelColor = getColorValue('gray-600', DEFAULT_COLOR_SCHEME); + + expect(byName(signals, 'valueTextColor')?.value).toBe(expectedValueColor); + expect(byName(signals, 'labelTextColor')?.value).toBe(expectedLabelColor); + + // showAsPercent default wiring + expect(byName(signals, 'showAsPercent')?.update).toBe(`${defaultGaugeOptions.showsAsPercent}`); + + // textSignal formatting expression + expect(byName(signals, 'textSignal')?.update).toBe("showAsPercent ? format((currVal / arcMaxVal) * 100, '.2f') + '%' : format(currVal, '.0f')"); + + // --- band ranges: default formulas --- + expect(byName(signals, 'bandRange')?.update).toBe('arcMaxVal - arcMinVal'); + expect(byName(signals, 'band1StartVal')?.update).toBe('arcMinVal'); + expect(byName(signals, 'band1EndVal')?.update).toBe('arcMinVal + bandRange * band1EndPct'); + expect(byName(signals, 'band2StartVal')?.update).toBe('band1EndVal'); + expect(byName(signals, 'band2EndVal')?.update).toBe('arcMinVal + bandRange * band2EndPct'); + expect(byName(signals, 'band3StartVal')?.update).toBe('band2EndVal'); + expect(byName(signals, 'band3EndVal')?.update).toBe('arcMaxVal'); + + // --- band angle mapping formulas --- + expect(byName(signals, 'band1StartAngle')?.update).toBe("scale('angleScale', band1StartVal)"); + expect(byName(signals, 'band1EndAngle')?.update).toBe("scale('angleScale', band1EndVal)"); + expect(byName(signals, 'band2StartAngle')?.update).toBe("scale('angleScale', band2StartVal)"); + expect(byName(signals, 'band2EndAngle')?.update).toBe("scale('angleScale', band2EndVal)"); + expect(byName(signals, 'band3StartAngle')?.update).toBe("scale('angleScale', band3StartVal)"); + expect(byName(signals, 'band3EndAngle')?.update).toBe("scale('angleScale', band3EndVal)"); + + // --- band gap geometry formulas (band 1) --- + expect(byName(signals, 'band1GapX')?.update).toBe("centerX + ( innerRadius - 5 ) * cos(band1EndAngle - PI/2)"); + expect(byName(signals, 'band1GapY')?.update).toBe("centerY + ( innerRadius - 5 ) * sin(band1EndAngle - PI/2)"); + expect(byName(signals, 'band1GapX2')?.update).toBe("centerX + ( outerRadius + 5 ) * cos(band1EndAngle - PI/2)"); + expect(byName(signals, 'band1GapY2')?.update).toBe("centerY + ( outerRadius + 5 ) * sin(band1EndAngle - PI/2)"); + + // --- band gap geometry formulas (band 2) --- + expect(byName(signals, 'band2GapX')?.update).toBe("centerX + ( innerRadius - 5 ) * cos(band2EndAngle - PI/2)"); + expect(byName(signals, 'band2GapY')?.update).toBe("centerY + ( innerRadius - 5 ) * sin(band2EndAngle - PI/2)"); + expect(byName(signals, 'band2GapX2')?.update).toBe("centerX + ( outerRadius + 5 ) * cos(band2EndAngle - PI/2)"); + expect(byName(signals, 'band2GapY2')?.update).toBe("centerY + ( outerRadius + 5 ) * sin(band2EndAngle - PI/2)"); + + }); + + test('applies user overrides (colorScheme, color, min/max, metric, name, index)', () => { + const overrides = { + ...defaultGaugeOptions, + colorScheme: 'dark' as const, + color: spectrumColors.dark['yellow-900'], + backgroundFill: spectrumColors.dark['gray-200'], + minArcValue: 50, + maxArcValue: 500, + metric: 'myMetric', + name: 'Revenue Gauge', + index: 2, + }; + + const newSpec = addGauge(spec, overrides); + const signals = newSpec.signals as any[]; + const expectedTargetStrokeDark = getColorValue('gray-900', 'dark'); + const expectedValueColorDark = getColorValue('gray-900', 'dark'); + const expectedLabelColorDark = getColorValue('gray-600', 'dark'); + + expect(byName(signals, 'targetLineStroke')?.value).toBe(expectedTargetStrokeDark); + expect(byName(signals, 'valueTextColor')?.value).toBe(expectedValueColorDark); + expect(byName(signals, 'labelTextColor')?.value).toBe(expectedLabelColorDark); + + // min/max should reflect overrides + expect(byName(signals, 'arcMinVal')?.value).toBe(50); + expect(byName(signals, 'arcMaxVal')?.value).toBe(500); + + // metric override reflected in currVal + expect(byName(signals, 'currVal')?.update).toBe("data('table')[0].myMetric"); + + // background fill should read from dark scheme + expect(byName(signals, 'backgroundfillColor')?.value).toBe(spectrumColors.dark['gray-200']); + + // filler color should be computed via getColorValue with dark scheme + const expectedFillerDark = getColorValue(spectrumColors.dark['yellow-900'], 'dark'); + expect(byName(signals, 'fillerColorToCurrVal')?.value).toBe(expectedFillerDark); + + // sanity: start/end angles remain the same defaults + expect(byName(signals, 'startAngle')?.update).toBe('-PI * 2 / 3'); + expect(byName(signals, 'endAngle')?.update).toBe('PI * 2 / 3'); + }); + +describe('getGaugeSignals – performanceRanges overrides', () => { + let spec: ScSpec; + + beforeEach(() => { + spec = { data: [], marks: [], scales: [], usermeta: {} }; + }); + + test('binds band pct signals to options.performanceRanges', () => { + const performanceRanges = [ + { bandEndPct: 0.4, fill: 'red' }, + { bandEndPct: 0.75, fill: 'yellow' }, + { bandEndPct: 1, fill: 'green' }, + ]; + + const overrides = { + ...defaultGaugeOptions, + performanceRanges, + }; + + const newSpec = addGauge(spec, overrides); + const signals = newSpec.signals as any[]; + + expect(byName(signals, 'band1EndPct')?.value).toBe(0.4); + expect(byName(signals, 'band2EndPct')?.value).toBe(0.75); + expect(byName(signals, 'band3EndPct')?.value).toBe(1); + }); + }); + + +}); + diff --git a/packages/vega-spec-builder/src/gauge/gaugeSpecBuilder.ts b/packages/vega-spec-builder/src/gauge/gaugeSpecBuilder.ts new file mode 100644 index 000000000..bc6994689 --- /dev/null +++ b/packages/vega-spec-builder/src/gauge/gaugeSpecBuilder.ts @@ -0,0 +1,166 @@ +/* + * Copyright 2025 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 { produce } from 'immer'; +import { Signal, Scale, Data } from 'vega'; +import { addGaugeMarks } from './gaugeMarkUtils'; + + +import { DEFAULT_COLOR_SCHEME } from '@spectrum-charts/constants'; + +// import { getColorValue, spectrumColors } from '@spectrum-charts/themes'; +import { getColorValue, spectrumColors } from '@spectrum-charts/themes'; +import { toCamelCase } from '@spectrum-charts/utils'; + +import { ColorScheme, GaugeOptions, GaugeSpecOptions, PerformanceRanges, ScSpec } from '../types'; +import { getGaugeTableData } from './gaugeDataUtils'; + +const DEFAULT_COLOR = spectrumColors.light['blue-900']; + +export const DEFAULT_PERFORMANCE_RANGES: PerformanceRanges[] = [ + { bandEndPct: 0.55, fill: spectrumColors.light['red-900'] }, + { bandEndPct: 0.8, fill: spectrumColors.light['yellow-400'] }, + { bandEndPct: 1, fill: spectrumColors.light['green-700'] }, +]; + + + +/** + * Adds a simple Gauge chart to the spec + * + */ +export const addGauge = produce< + ScSpec, + [GaugeOptions & { colorScheme?: ColorScheme; index?: number; idKey: string }] +>( + ( + spec, + { + colorScheme = DEFAULT_COLOR_SCHEME, + index = 0, + color = DEFAULT_COLOR, + performanceRanges, + showPerformanceRanges = false, + name, + ...options + } + ) => { + const resolvedPerformanceRanges = + (performanceRanges ?? DEFAULT_PERFORMANCE_RANGES).map(range => ({ + ...range, + fill: getColorValue(range.fill, DEFAULT_COLOR_SCHEME), + })); + const gaugeOptions: GaugeSpecOptions = { + backgroundFill: spectrumColors[colorScheme]['gray-200'], + backgroundStroke: spectrumColors[colorScheme]['gray-300'], + color: getColorValue(color, colorScheme), + fillerColorSignal: 'fillerColorToCurrVal', + graphLabel: 'graphLabel', + showsAsPercent: false, + showLabel: false, + colorScheme: colorScheme, + index, + maxArcValue: 100, + minArcValue: 0, + metric: 'currentAmount', + target: 'target', + name: toCamelCase(name ?? `gauge${index}`), + needle: false, + targetLine: false, + performanceRanges: resolvedPerformanceRanges, + showPerformanceRanges: showPerformanceRanges ?? false, + ...options, + }; + + spec.signals = addSignals(spec.signals ?? [], gaugeOptions); + spec.scales = addScales(spec.scales ?? [], gaugeOptions); + spec.marks = addGaugeMarks(spec.marks ?? [], gaugeOptions); + spec.data = addData(spec.data ?? [], gaugeOptions); + + } +); + +export const addSignals = produce<Signal[], [GaugeSpecOptions]>((signals, options) => { + const ranges = options.performanceRanges ?? DEFAULT_PERFORMANCE_RANGES; + + signals.push({ name: 'arcMaxVal', value: options.maxArcValue }) + signals.push({ name: 'arcMinVal', value: options.minArcValue }) + signals.push({ name: 'backgroundfillColor', value: `${options.backgroundFill}`}) + signals.push({ name: 'centerX', update: "width/2"}) + signals.push({ name: 'centerY', update: "height/2 + outerRadius/2"}) + signals.push({ name: 'clampedVal', update: "min(max(arcMinVal, currVal), arcMaxVal)"}) + signals.push({ name: 'currVal', update: `data('table')[0].${options.metric}` }) + signals.push({ name: 'endAngle', update: "PI * 2 / 3" }) + signals.push({ name: 'fillerColorToCurrVal', value: `${options.color}`}); + signals.push({ name: 'needleColor', update: options.showPerformanceRanges? `"${getColorValue('gray-900', options.colorScheme)}"`: `"${options.color}"`}); + signals.push({ name: 'graphLabel', update: `data('table')[0].${options.graphLabel}` }) + signals.push({ name: 'innerRadius', update: "outerRadius - (radiusRef * 0.25)"}) + signals.push({ name: 'needleAngleTarget', update: "needleAngleTargetVal - PI/2"}) + signals.push({ name: 'needleAngleDeg', update: "needleAngleClampedVal * 180 / PI"}) + signals.push({ name: 'needleAngleClampedVal', update: "scale('angleScale', clampedVal)"}) + signals.push({ name: 'needleAngleTargetVal', update: "scale('angleScale', target)"}) + signals.push({ name: 'needleLength', update: "30"}) + signals.push({ name: 'outerRadius', update: "radiusRef * 0.95"}) + signals.push({ name: 'radiusRef', update: "min(width/2, height/2)"}) + signals.push({ name: 'startAngle', update: "-PI * 2 / 3" }) + signals.push({ name: 'theta', update: "scale('angleScale', clampedVal)"}) + signals.push({ name: 'target', update: `data('table')[0].${options.target}`}) + signals.push({ name: 'targetLineStroke', value: getColorValue('gray-900', options.colorScheme)}) + signals.push({ name: 'targetLineX', update: "centerX + ( innerRadius - 5) * cos(needleAngleTarget)"}) + signals.push({ name: 'targetLineY', update: "centerY + ( innerRadius - 5) * sin(needleAngleTarget)"}) + signals.push({ name: 'targetLineX2', update: "centerX + ( outerRadius + 5) * cos(needleAngleTarget)"}) + signals.push({ name: 'targetLineY2', update: "centerY + ( outerRadius + 5) * sin(needleAngleTarget)"}) + signals.push({ name: 'showAsPercent', update: `${options.showsAsPercent}`}) + signals.push({ name: 'valueTextColor', value: getColorValue('gray-900', options.colorScheme) }) + signals.push({ name: 'labelTextColor', value: getColorValue('gray-600', options.colorScheme) }) + signals.push({ name: 'textSignal', update: "showAsPercent ? format((currVal / arcMaxVal) * 100, '.2f') + '%' : format(currVal, '.0f')"}) + + signals.push({ name: 'band1EndPct', value: ranges[0].bandEndPct }); + signals.push({ name: 'band2EndPct', value: ranges[1].bandEndPct }); + signals.push({ name: 'band3EndPct', value: ranges[2].bandEndPct }); + signals.push({ name: 'bandRange', update: 'arcMaxVal - arcMinVal' }); + signals.push({ name: 'band1StartVal', update: 'arcMinVal' }); + signals.push({ name: 'band1EndVal', update: 'arcMinVal + bandRange * band1EndPct' }); + signals.push({ name: 'band2StartVal', update: 'band1EndVal' }); + signals.push({ name: 'band2EndVal', update: 'arcMinVal + bandRange * band2EndPct' }); + signals.push({ name: 'band3StartVal', update: 'band2EndVal' }); + signals.push({ name: 'band3EndVal', update: 'arcMaxVal' }); + signals.push({ name: 'band1StartAngle', update: "scale('angleScale', band1StartVal)" }); + signals.push({ name: 'band1EndAngle', update: "scale('angleScale', band1EndVal)" }); + signals.push({ name: 'band2StartAngle', update: "scale('angleScale', band2StartVal)" }); + signals.push({ name: 'band2EndAngle', update: "scale('angleScale', band2EndVal)" }); + signals.push({ name: 'band3StartAngle', update: "scale('angleScale', band3StartVal)" }); + signals.push({ name: 'band3EndAngle', update: "scale('angleScale', band3EndVal)" }); + + signals.push({ name: 'band1GapX', update: "centerX + ( innerRadius - 5 ) * cos(band1EndAngle - PI/2)"}) + signals.push({ name: 'band1GapY', update: "centerY + ( innerRadius - 5 ) * sin(band1EndAngle - PI/2)"}) + signals.push({ name: 'band1GapX2', update: "centerX + ( outerRadius + 5 ) * cos(band1EndAngle - PI/2)"}) + signals.push({ name: 'band1GapY2', update: "centerY + ( outerRadius + 5 ) * sin(band1EndAngle - PI/2)"}) + + signals.push({ name: 'band2GapX', update: "centerX + ( innerRadius - 5 ) * cos(band2EndAngle - PI/2)"}) + signals.push({ name: 'band2GapY', update: "centerY + ( innerRadius - 5 ) * sin(band2EndAngle - PI/2)"}) + signals.push({ name: 'band2GapX2', update: "centerX + ( outerRadius + 5 ) * cos(band2EndAngle - PI/2)"}) + signals.push({ name: 'band2GapY2', update: "centerY + ( outerRadius + 5 ) * sin(band2EndAngle - PI/2)"}) +}); + +export const addScales = produce<Scale[], [GaugeSpecOptions]>((scales, options) => { + scales.push({ + name: 'angleScale', + type: 'linear', + domain: [{ signal: 'arcMinVal' }, { signal: 'arcMaxVal' }], + range: [{ signal: 'startAngle' }, { signal: 'endAngle' }], + clamp: true + }); +}); + +export const addData = produce<Data[], [GaugeSpecOptions]>((data, options) => { + const tableData = getGaugeTableData(data); +}); diff --git a/packages/vega-spec-builder/src/gauge/gaugeTestUtils.ts b/packages/vega-spec-builder/src/gauge/gaugeTestUtils.ts new file mode 100644 index 000000000..a1683f8f2 --- /dev/null +++ b/packages/vega-spec-builder/src/gauge/gaugeTestUtils.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2025 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 { GaugeSpecOptions } from '../types'; +import { spectrumColors } from '@spectrum-charts/themes'; +import { DEFAULT_COLOR_SCHEME } from '@spectrum-charts/constants'; +import { MARK_ID } from '@spectrum-charts/constants'; + +export const defaultGaugeOptions: GaugeSpecOptions = { + colorScheme: DEFAULT_COLOR_SCHEME, + idKey: MARK_ID, + index: 5, + name: 'gaugeTestName', + graphLabel: 'graphLabel', + showLabel: false, + showsAsPercent: false, + metric: 'currentAmount', + color: DEFAULT_COLOR_SCHEME, + minArcValue: 0, + maxArcValue: 100, + backgroundFill: spectrumColors[DEFAULT_COLOR_SCHEME]['gray-200'], + backgroundStroke: spectrumColors[DEFAULT_COLOR_SCHEME]['gray-300'], + fillerColorSignal: 'light', + needle: false, + target: 'target', + targetLine: false, + showPerformanceRanges: false, + performanceRanges: [ + { bandEndPct: 0.55, fill: spectrumColors[DEFAULT_COLOR_SCHEME]['red-900'] }, + { bandEndPct: 0.8, fill: spectrumColors[DEFAULT_COLOR_SCHEME]['yellow-400'] }, + { bandEndPct: 1, fill: spectrumColors[DEFAULT_COLOR_SCHEME]['green-700'] }, + ] +}; diff --git a/packages/vega-spec-builder/src/types/chartSpec.types.ts b/packages/vega-spec-builder/src/types/chartSpec.types.ts index 70f415a82..408065b52 100644 --- a/packages/vega-spec-builder/src/types/chartSpec.types.ts +++ b/packages/vega-spec-builder/src/types/chartSpec.types.ts @@ -20,6 +20,7 @@ import { BulletOptions, ComboOptions, DonutOptions, + GaugeOptions, LineOptions, ScatterOptions, VennOptions, @@ -58,6 +59,7 @@ export type MarkOptions = | BulletOptions | ComboOptions | DonutOptions + | GaugeOptions | LineOptions | ScatterOptions | VennOptions; diff --git a/packages/vega-spec-builder/src/types/marks/gaugeSpec.types.ts b/packages/vega-spec-builder/src/types/marks/gaugeSpec.types.ts new file mode 100644 index 000000000..671340729 --- /dev/null +++ b/packages/vega-spec-builder/src/types/marks/gaugeSpec.types.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2025 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 { ColorScheme } from '../chartSpec.types'; +import { NumberFormat, PartiallyRequired } from '../specUtil.types'; + +export type PerformanceRanges = { bandEndPct: number; fill: string }; + +export interface GaugeOptions { + /** Sets the name of the component. */ + name?: string; + /** Key in the data that is used as the graph label */ + graphLabel?: string; + /** Sets to show the label or not */ + showLabel?: boolean; + /** Sets to show the current value as a percentage or not */ + showsAsPercent?: boolean; + /** Key in the data that is used as the metric */ + metric?: string; + /** Key in the data that is used as the color facet */ + color?: string; + /** Minimum value for the scale. This value must be greater than zero, and less than maxArcValue */ + minArcValue?: number; + /** Maximum value for the scale. This value must be greater than zero, and greater than minArcValue */ + maxArcValue?: number; + /** Color of the background arc */ + backgroundFill?: string; + /** Color of the background stroke */ + backgroundStroke?: string; + /** Color of the filler color arc */ + fillerColorSignal?: string; + /** Showing the needle mark */ + needle?: boolean; + /** Key in the data that is used as the target */ + target?: string; + /** Showing the target line */ + targetLine?: boolean; + /** Array of performance ranges to be rendered as filled bands on the gauge. */ + performanceRanges?: PerformanceRanges[]; + /** If true, show banded performance ranges instead of a colored filler arc */ + showPerformanceRanges?: boolean; + +} + +type GaugeOptionsWithDefaults = + | 'name' + | 'graphLabel' + | 'showLabel' + | 'showsAsPercent' + | 'metric' + | 'color' + | 'minArcValue' + | 'maxArcValue' + | 'backgroundFill' + | 'backgroundStroke' + | 'fillerColorSignal' + | 'needle' + | 'target' + | 'targetLine' + | 'performanceRanges' + | 'showPerformanceRanges' + +export interface GaugeSpecOptions extends PartiallyRequired<GaugeOptions, GaugeOptionsWithDefaults> { + colorScheme: ColorScheme; + idKey: string; + index: number; +} diff --git a/packages/vega-spec-builder/src/types/marks/index.ts b/packages/vega-spec-builder/src/types/marks/index.ts index b0bc1076f..d84369eae 100644 --- a/packages/vega-spec-builder/src/types/marks/index.ts +++ b/packages/vega-spec-builder/src/types/marks/index.ts @@ -14,6 +14,7 @@ export * from './areaSpec.types'; export * from './barSpec.types'; export * from './bigNumberSpec.types'; export * from './bulletSpec.types'; +export * from './gaugeSpec.types'; export * from './comboSpec.types'; export * from './donutSpec.types'; export * from './lineSpec.types'; diff --git a/yarn.lock b/yarn.lock index 5664a7126..132dd5a3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3078,6 +3078,11 @@ utility-types "^3.10.0" webpack "^5.88.1" +"@epic-web/invariant@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@epic-web/invariant/-/invariant-1.0.0.tgz#1073e5dee6dd540410784990eb73e4acd25c9813" + integrity sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA== + "@es-joy/jsdoccomment@~0.49.0": version "0.49.0" resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.49.0.tgz#e5ec1eda837c802eca67d3b29e577197f14ba1db" @@ -8181,15 +8186,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001716: - version "1.0.30001718" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz#dae13a9c80d517c30c6197515a96131c194d8f82" - integrity sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw== - -caniuse-lite@^1.0.30001669: - version "1.0.30001677" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001677.tgz#27c2e2c637e007cfa864a16f7dfe7cde66b38b5f" - integrity sha512-fmfjsOlJUpMWu+mAAtZZZHz7UEwsUxIIvu1TJfO1HqFQvB/B+ii0xr9B5HpbZY/mC4XZ8SvjHJqtAY6pDPQEog== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001669, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001716: + version "1.0.30001754" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz" + integrity sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg== capital-case@^1.0.4: version "1.0.4" @@ -8815,14 +8815,15 @@ create-jest@^29.7.0: jest-util "^29.7.0" prompts "^2.0.1" -cross-env@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" - integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== +cross-env@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-10.1.0.tgz#cfd2a6200df9ed75bfb9cb3d7ce609c13ea21783" + integrity sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw== dependencies: - cross-spawn "^7.0.1" + "@epic-web/invariant" "^1.0.0" + cross-spawn "^7.0.6" -cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==