Repository: adobe/react-spectrum-charts
Pull request #533: Scrum 28/flexible max scale
Author: login: jonesgg
Created: 2025-03-26T22:07:45Z | Merged: 2025-04-01T17:26:50Z

## Description

Adds functionality for fixed and flexible labeling

## How Has This Been Tested?

Relevant tests have been added

## Screenshots (if appropriate):

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] I have signed the [Adobe Open Source CLA](https://opensource.adobe.com/cla.html).
- [x] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.
- [x] I have updated the documentation accordingly.
- [x] I have read the **CONTRIBUTING** document.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.



Diff:
diff --git a/src/constants.ts b/src/constants.ts
index 87a4988f8..1377a2822 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -33,6 +33,8 @@ export const DEFAULT_LINE_TYPES = ['solid', 'dashed', 'dotted', 'dotDash', 'long
 export const DEFAULT_LINEAR_DIMENSION = 'x';
 export const DEFAULT_LOCALE = 'en-US';
 export const DEFAULT_METRIC = 'value';
+export const DEFAULT_SCALE_TYPE = 'normal';
+export const DEFAULT_SCALE_VALUE = 100;
 export const DEFAULT_SECONDARY_COLOR = 'subSeries';
 export const DEFAULT_SYMBOL_SIZE = 100;
 export const DEFAULT_SYMBOL_STROKE_WIDTH = 2;
diff --git a/src/specBuilder/bullet/bulletMarkUtils.test.ts b/src/specBuilder/bullet/bulletMarkUtils.test.ts
index 8a469e85f..98241beb4 100644
--- a/src/specBuilder/bullet/bulletMarkUtils.test.ts
+++ b/src/specBuilder/bullet/bulletMarkUtils.test.ts
@@ -80,6 +80,12 @@ describe('getBulletData', () => {
 		const data = getBulletData(samplePropsColumn);
 		expect(data).toHaveLength(1);
 	});
+
+	test('Should return the correct data object in flexible scale mode', () => {
+		const props = { ...samplePropsColumn, scaleType: 'flexible' as 'normal' | 'flexible' | 'fixed' };
+		const data = getBulletData(props);
+		expect(data[0].transform).toHaveLength(2);
+	});
 });
 
 describe('getBulletScales', () => {
@@ -102,6 +108,43 @@ describe('getBulletScales', () => {
 			expect(data[0].range[1].signal).toBe('width');
 		}
 	});
+
+	test('Should return the correct scales object for flexible scale mode', () => {
+		const props = { ...samplePropsColumn, scaleType: 'flexible' as 'normal' | 'flexible' | 'fixed' };
+		const data = getBulletScales(props);
+		expect(data).toBeDefined();
+		expect(data[1].domain).toBeDefined();
+		expect(data[1].domain).toStrictEqual({
+			data: 'table',
+			fields: ['xPaddingForTarget', props.metric, 'flexibleScaleValue']
+		});
+	});
+
+	test('Should return the correct scales object for fixed scale mode', () => {
+		const props = { ...samplePropsColumn, scaleType: 'fixed' as 'normal' | 'flexible' | 'fixed' };
+		const data = getBulletScales(props);
+		expect(data).toBeDefined();
+		expect(data[1].domain).toBeDefined();
+		expect(data[1].domain).toStrictEqual([0,`${props.maxScaleValue}`]);
+	});
+
+	test('Should return the correct scales object for normal scale mode', () => {
+		const props = { ...samplePropsColumn, scaleType: 'normal' as 'normal' | 'flexible' | 'fixed' };
+		const data = getBulletScales(props);
+		expect(data).toBeDefined();
+		expect(data[1].domain).toBeDefined();
+		expect(data[1].domain).toStrictEqual({data: 'table',fields:['xPaddingForTarget', props.metric]});
+	});
+
+	test('Should return the correct scales object when a negative value is passed for maxScaleValue', () => {
+		const props = { ...samplePropsColumn, scaleType: 'fixed' as 'normal' | 'flexible' | 'fixed', maxScaleValue: -100 };
+		const data = getBulletScales(props);
+		expect(data).toBeDefined();
+		expect(data[1].domain).toBeDefined();
+
+		// Expect normal scale mode to be used
+		expect(data[1].domain).toStrictEqual({data: 'table',fields:['xPaddingForTarget', props.metric]});
+	});
 });
 
 describe('getBulletSignals', () => {
diff --git a/src/specBuilder/bullet/bulletMarkUtils.ts b/src/specBuilder/bullet/bulletMarkUtils.ts
index 28af77665..04686eba9 100644
--- a/src/specBuilder/bullet/bulletMarkUtils.ts
+++ b/src/specBuilder/bullet/bulletMarkUtils.ts
@@ -17,6 +17,14 @@ import { getColorValue } from '../specUtils';
 export function getBulletScales(props: BulletSpecProps): Scale[] {
 	const groupScaleRangeSignal = props.direction === 'column' ? 'height' : 'width';
 	const xRange = props.direction === 'column' ? 'width' : [0, { signal: 'bulletGroupWidth' }];
+	let domainFields;
+	if (props.scaleType === 'flexible' && props.maxScaleValue > 0) {
+		domainFields = { data: 'table', fields: ['xPaddingForTarget', props.metric, 'flexibleScaleValue'] };
+	} else if (props.scaleType === 'fixed' && props.maxScaleValue > 0) {
+		domainFields = [0, `${props.maxScaleValue}`];
+	} else {
+		domainFields = { data: 'table', fields: ['xPaddingForTarget', props.metric] };
+	}
 
 	const bulletScales: Scale[] = [
 		{
@@ -29,12 +37,14 @@ export function getBulletScales(props: BulletSpecProps): Scale[] {
 		{
 			name: 'xscale',
 			type: 'linear',
-			domain: { data: 'table', fields: ['xPaddingForTarget', props.metric] },
+			domain: domainFields,
 			range: xRange,
 			round: true,
+			clamp: true,
 			zero: true,
 		},
 	];
+
 	return bulletScales;
 }
 
@@ -97,6 +107,17 @@ export function getBulletData(props: BulletSpecProps): Data[] {
 		},
 	];
 
+	// In flexible scale mode the max scale value is added to each data field to make sure that the scale calculation is accurate.
+	// The flexible scale max value is calculated by taking the max value of the metric, target value, and the flexible scale value
+	// of all data fields.
+	if (props.scaleType === 'flexible') {
+		bulletData[0].transform?.push({
+			type: 'formula',
+			expr: `${props.maxScaleValue}`,
+			as: 'flexibleScaleValue',
+		});
+	}
+
 	return bulletData;
 }
 
diff --git a/src/specBuilder/bullet/bulletSpecBuilder.test.ts b/src/specBuilder/bullet/bulletSpecBuilder.test.ts
index eab38be2b..835863614 100644
--- a/src/specBuilder/bullet/bulletSpecBuilder.test.ts
+++ b/src/specBuilder/bullet/bulletSpecBuilder.test.ts
@@ -28,6 +28,8 @@ export const samplePropsColumn: BulletSpecProps = {
 	showTarget: true,
 	showTargetValue: false,
 	labelPosition: 'top',
+	scaleType: 'normal',
+	maxScaleValue: 100,
 };
 
 export const samplePropsRow: BulletSpecProps = {
@@ -44,6 +46,8 @@ export const samplePropsRow: BulletSpecProps = {
 	showTarget: true,
 	showTargetValue: false,
 	labelPosition: 'top',
+	scaleType: 'normal',
+	maxScaleValue: 100,
 };
 
 describe('addBullet', () => {
diff --git a/src/specBuilder/bullet/bulletSpecBuilder.ts b/src/specBuilder/bullet/bulletSpecBuilder.ts
index 4e385a72b..00cfab1a5 100644
--- a/src/specBuilder/bullet/bulletSpecBuilder.ts
+++ b/src/specBuilder/bullet/bulletSpecBuilder.ts
@@ -9,7 +9,7 @@
  * OF ANY KIND, either express or implied. See the License for the specific language
  * governing permissions and limitations under the License.
  */
-import { DEFAULT_BULLET_DIRECTION, DEFAULT_COLOR_SCHEME, DEFAULT_LABEL_POSITION } from '@constants';
+import { DEFAULT_BULLET_DIRECTION, DEFAULT_COLOR_SCHEME, DEFAULT_LABEL_POSITION, DEFAULT_SCALE_TYPE, DEFAULT_SCALE_VALUE } from '@constants';
 import { spectrumColors } from '@themes';
 import { toCamelCase } from '@utils';
 import { Spec } from 'vega';
@@ -37,6 +37,8 @@ export const addBullet = (
 		showTarget = true,
 		showTargetValue = false,
 		labelPosition = DEFAULT_LABEL_POSITION,
+		scaleType = DEFAULT_SCALE_TYPE,
+		maxScaleValue = DEFAULT_SCALE_VALUE,
 		...props
 	}: BulletProps & { colorScheme?: ColorScheme; index?: number; idKey: string }
 ): Spec => {
@@ -54,6 +56,8 @@ export const addBullet = (
 		showTarget: showTarget,
 		showTargetValue: showTargetValue,
 		labelPosition: labelPosition,
+		scaleType: scaleType,
+		maxScaleValue: maxScaleValue,
 		...props,
 	};
 
diff --git a/src/stories/components/Bullet/Bullet.story.tsx b/src/stories/components/Bullet/Bullet.story.tsx
index 041ae120a..a3e430bbc 100644
--- a/src/stories/components/Bullet/Bullet.story.tsx
+++ b/src/stories/components/Bullet/Bullet.story.tsx
@@ -36,7 +36,7 @@ const BulletStory: StoryFn<BulletProps & { width?: number; height?: number }> =
 	const chartProps = useChartProps({ ...defaultChartProps, width: width ?? 350, height: height ?? 350 });
 	return (
 		<Chart {...chartProps}>
-			<Bullet {...bulletProps} />
+			<Bullet {...bulletProps}/> 
 		</Chart>
 	);
 };
@@ -57,13 +57,30 @@ Basic.args = {
 	metric: 'currentAmount',
 	dimension: 'graphLabel',
 	target: 'target',
-	thresholds: basicThresholdsData,
 	color: 'red-500',
 	direction: 'column',
 	numberFormat: '$,.2f',
-  showTarget: true,
-  showTargetValue: false,
-  labelPosition: 'top',
+	showTarget: true,
+	showTargetValue: false,
+	labelPosition: 'top',
+	scaleType: 'normal',
+	maxScaleValue: 100,
+};
+
+const Thresholds = bindWithProps(BulletStory);
+Thresholds.args = {
+	metric: 'currentAmount',
+	dimension: 'graphLabel',
+	target: 'target',
+	color: 'red-500',
+	direction: 'column',
+	numberFormat: '$,.2f',
+	showTarget: true,
+	showTargetValue: false,
+	labelPosition: 'top',
+	scaleType: 'normal',
+	maxScaleValue: 100,
+	thresholds: basicThresholdsData,
 };
 
 const RowMode = bindWithProps(BulletStory);
@@ -71,24 +88,49 @@ RowMode.args = {
 	metric: 'currentAmount',
 	dimension: 'graphLabel',
 	target: 'target',
-	thresholds: basicThresholdsData,
 	color: 'red-500',
 	direction: 'row',
 	numberFormat: '$,.2f',
 	showTarget: true,
 	showTargetValue: false,
-  labelPosition: 'top',
+	labelPosition: 'top',
+	scaleType: 'normal',
+	maxScaleValue: 100,
+	thresholds: basicThresholdsData,
 };
 
 const WithTitle = bindWithProps(BulletTitleStory);
 WithTitle.args = {
-    metric: 'currentAmount',
-    dimension: 'graphLabel',
-    target: 'target',
-    color: 'red-500',
-    numberFormat: '$,.2f',
-    labelPosition: 'top',
+	metric: 'currentAmount',
+	dimension: 'graphLabel',
+	target: 'target',
+	color: 'red-500',
+	direction: 'column',
+	numberFormat: '$,.2f',
+	showTarget: true,
+	showTargetValue: false,
+	labelPosition: 'top',
+	scaleType: 'normal',
+	maxScaleValue: 100,
+	thresholds: basicThresholdsData,
+};
+
+const FixedScale = bindWithProps(BulletStory);
+FixedScale.args = {
+	metric: 'currentAmount',
+	dimension: 'graphLabel',
+	target: 'target',
+	color: 'red-500',
+	direction: 'column',
+	numberFormat: '$,.2f',
+	showTarget: true,
+	showTargetValue: false,
+	labelPosition: 'top',
+	scaleType: 'fixed',
+	maxScaleValue: 250,
+	thresholds: basicThresholdsData,
 };
-export { Basic, RowMode, WithTitle };
+
+export { Basic, Thresholds, RowMode, WithTitle, FixedScale };
 
 
diff --git a/src/types/Chart.ts b/src/types/Chart.ts
index d33466867..5ec5d4909 100644
--- a/src/types/Chart.ts
+++ b/src/types/Chart.ts
@@ -243,6 +243,18 @@ export interface BulletProps extends MarkProps {
 	numberFormat?: NumberFormat;
 	/** Specifies if the labels should be in top of the bullet chart or to the side. Side labels are not supported in row mode. */
 	labelPosition?: 'side' | 'top';
+	/** Specifies if the scale should be normal, fixed, or flexible.
+	 * 
+	 * In normal mode the maximum scale value will be calculated using the maximum value of the metric and target data fields. 
+	 * 
+	 * In fixed mode the maximum scale value will be set as the maxScaleValue prop.
+	 * 
+	 * In flexible mode the maximum scale value will be calculated using the maximum value of either the maxScaleValue prop or maximum value of the metric and target data fields.
+	 * This means that the scale max will be set to be the maxScaleValue prop until the data values overtake it.
+	 */
+	scaleType?: 'normal' | 'fixed' | 'flexible';
+	/** Maximum value for the scale. This value must be greater than zero. */
+	maxScaleValue?: number;
 	/** Array of threshold definitions to be rendered as background bands on the bullet chart.
 	 *
 	 *  Each threshold object supports:
diff --git a/src/types/specBuilderTypes.ts b/src/types/specBuilderTypes.ts
index 31dc3cc32..77d2d4c97 100644
--- a/src/types/specBuilderTypes.ts
+++ b/src/types/specBuilderTypes.ts
@@ -141,7 +141,7 @@ export interface ChartPopoverSpecProps extends PartiallyRequired<ChartPopoverPro
 	markName: string;
 }
 
-type BulletPropsWithDefaults = 'name' | 'metric' | 'dimension' | 'target' | 'color' | 'direction'| 'showTarget' | 'showTargetValue' | 'labelPosition';
+type BulletPropsWithDefaults = 'name' | 'metric' | 'dimension' | 'target' | 'color' | 'direction'| 'showTarget' | 'showTargetValue' | 'labelPosition' | 'scaleType' | 'maxScaleValue';
 
 export interface BulletSpecProps extends PartiallyRequired<BulletProps, BulletPropsWithDefaults> {
 	children: MarkChildElement[];


Reviews:
user: login: spenjnb
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T08:56:09Z
user: login: spenjnb
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T09:06:26Z
user: login: spenjnb
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T09:08:34Z
user: login: jonesgg
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T16:58:29Z
user: login: c-lamoureux
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T17:22:32Z

Review comments:
diff_hunk: @@ -97,6 +107,14 @@ export function getBulletData(props: BulletSpecProps): Data[] {
 		},
 	];
 
+	if (props.scaleType === 'flexible') {
+		bulletData[0].transform?.push({
+			type: 'formula',
+			expr: `${props.maxScaleValue}`,
+			as: 'flexibleScaleValue',
+		});
+	}
+
path: src/specBuilder/bullet/bulletMarkUtils.ts
user: login: spenjnb
body: It could be good to add comment here to explain maxScaleValue. Like "Domain is at least `maxScaleValue` if data is smaller, but can grow if data is larger". `flexibleScaleValue` can have a comment that explains that this value is used to guarantee the domain includes at least `maxScaleValue` or if we don't want to add a comment maybe renaming it to `maxPossibleValue` could be more self-explanatory just an idea. 
created_at: 2025-04-01T08:56:09Z
author_association: COLLABORATOR
diff_hunk: @@ -243,6 +243,10 @@ export interface BulletProps extends MarkProps {
 	numberFormat?: NumberFormat;
 	/** Specifies if the labels should be in top of the bullet chart or to the side. Side labels are not supported in row mode. */
 	labelPosition?: 'side' | 'top';
+	/** Specifies if the scale should be normal, fixed, or flexible */
+	scaleType?: 'normal' | 'fixed' | 'flexible';
+	/** Maximum value for the scale */
+	maxScaleValue?: number;
path: src/types/Chart.ts
user: login: spenjnb
body: When we set scale to `flexible` if the data is bigger than maxScaleValue the domain can grow beyond that. We could maybe expand this comment to explain that this max value can be excided, or maybe give it a different name like minMaxScaleValue, just to make clear that `flexible` ensures maxScaleValue but can go larger if data is bigger.  
created_at: 2025-04-01T09:06:26Z
author_association: COLLABORATOR
diff_hunk: @@ -17,6 +17,14 @@ import { getColorValue } from '../specUtils';
 export function getBulletScales(props: BulletSpecProps): Scale[] {
 	const groupScaleRangeSignal = props.direction === 'column' ? 'height' : 'width';
 	const xRange = props.direction === 'column' ? 'width' : [0, { signal: 'bulletGroupWidth' }];
+	let domainFields;
+	if (props.scaleType === 'flexible') {
+		domainFields = { data: 'table', fields: ['xPaddingForTarget', props.metric, 'flexibleScaleValue'] };
+	} else if (props.scaleType === 'fixed') {
+		domainFields = [0, `${props.maxScaleValue}`];
path: src/specBuilder/bullet/bulletMarkUtils.ts
user: login: spenjnb
body: Do we want to handle negative data? The current implementation uses 0 for `fixed`, `normal` and `flexible`? Maybe we can just add one more test case for negative values.
created_at: 2025-04-01T09:08:34Z
author_association: COLLABORATOR
diff_hunk: @@ -17,6 +17,14 @@ import { getColorValue } from '../specUtils';
 export function getBulletScales(props: BulletSpecProps): Scale[] {
 	const groupScaleRangeSignal = props.direction === 'column' ? 'height' : 'width';
 	const xRange = props.direction === 'column' ? 'width' : [0, { signal: 'bulletGroupWidth' }];
+	let domainFields;
+	if (props.scaleType === 'flexible') {
+		domainFields = { data: 'table', fields: ['xPaddingForTarget', props.metric, 'flexibleScaleValue'] };
+	} else if (props.scaleType === 'fixed') {
+		domainFields = [0, `${props.maxScaleValue}`];
path: src/specBuilder/bullet/bulletMarkUtils.ts
user: login: jonesgg
body: I changed to force positive values. A test was added
created_at: 2025-04-01T16:58:29Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-26T23:30:26Z
body: 🎨 Storybook -> https://opensource.adobe.com/react-spectrum-charts/PR-533
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-27T22:25:48Z
body: 🎨 Storybook -> https://opensource.adobe.com/react-spectrum-charts/PR-533
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-31T20:10:54Z
body: 🎨 Storybook -> https://opensource.adobe.com/react-spectrum-charts/PR-533
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-31T22:42:37Z
body: 🎨 Storybook -> https://opensource.adobe.com/react-spectrum-charts/PR-533
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-01T17:01:33Z
body: 🎨 Storybook -> https://opensource.adobe.com/react-spectrum-charts/PR-533
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-01T17:14:13Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=adobe_react-spectrum-charts&pullRequest=533) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=adobe_react-spectrum-charts&pullRequest=533&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=adobe_react-spectrum-charts&pullRequest=533&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=adobe_react-spectrum-charts&pullRequest=533&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=adobe_react-spectrum-charts&pullRequest=533&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=adobe_react-spectrum-charts&pullRequest=533&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=adobe_react-spectrum-charts&pullRequest=533)


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-01T17:15:14Z
body: 🎨 Storybook -> https://opensource.adobe.com/react-spectrum-charts/PR-533
author_association: NONE