diff --git a/packages/react-spectrum-charts-s2/src/rc/components/Gauge/Gauge.tsx b/packages/react-spectrum-charts-s2/src/rc/components/Gauge/Gauge.tsx new file mode 100644 index 000000000..7cb381af1 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/rc/components/Gauge/Gauge.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2026 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_METRIC } from '@spectrum-charts/constants'; + +import { GaugeProps } from '../../../types'; + +const DEFAULT_ARC_SIZE = 2 / 3; +const DEFAULT_HOLE_RATIO = 0.8; +const DEFAULT_MAX_SCALE_VALUE = 100; +const DEFAULT_METHOD = 'last'; +const DEFAULT_MIN_SCALE_VALUE = 0; +const DEFAULT_NUMBER_FORMAT = 'shortNumber'; +const DEFAULT_SIZE = 'M'; + +// destructure props here and set defaults so that storybook can pick them up +const Gauge: FC = ({ + arcSize = DEFAULT_ARC_SIZE, + children, + color = 'categorical-01', + holeRatio = DEFAULT_HOLE_RATIO, + label, + maxScaleValue = DEFAULT_MAX_SCALE_VALUE, + method = DEFAULT_METHOD, + metric = DEFAULT_METRIC, + minScaleValue = DEFAULT_MIN_SCALE_VALUE, + name, + numberFormat = DEFAULT_NUMBER_FORMAT, + showNeedle = true, + showRangeLabels = false, + size = DEFAULT_SIZE, + target, + targetLabel, + thresholds, + ticks, +}) => { + 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-s2/src/rc/components/Gauge/index.ts b/packages/react-spectrum-charts-s2/src/rc/components/Gauge/index.ts new file mode 100644 index 000000000..dd9965c55 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/rc/components/Gauge/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2026 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-s2/src/rc/components/index.ts b/packages/react-spectrum-charts-s2/src/rc/components/index.ts index bb84bbae5..e69e5c607 100644 --- a/packages/react-spectrum-charts-s2/src/rc/components/index.ts +++ b/packages/react-spectrum-charts-s2/src/rc/components/index.ts @@ -12,4 +12,5 @@ export * from './Donut'; export * from './DonutSummary'; +export * from './Gauge'; export * from './SegmentLabel'; diff --git a/packages/react-spectrum-charts-s2/src/rscToSbAdapter/childrenAdapter.ts b/packages/react-spectrum-charts-s2/src/rscToSbAdapter/childrenAdapter.ts index 6d9256879..93dd5db7d 100644 --- a/packages/react-spectrum-charts-s2/src/rscToSbAdapter/childrenAdapter.ts +++ b/packages/react-spectrum-charts-s2/src/rscToSbAdapter/childrenAdapter.ts @@ -37,7 +37,7 @@ import { Line } from '../components/Line'; import { LineDirectLabel } from '../components/LineDirectLabel'; import { ReferenceLine } from '../components/ReferenceLine'; import { Title } from '../components/Title'; -import { Donut, DonutSummary, SegmentLabel } from '../rc'; +import { Donut, DonutSummary, Gauge, SegmentLabel } from '../rc'; import { AxisProps, AxisThumbnailProps, @@ -47,6 +47,7 @@ import { ChartTooltipProps, DonutProps, DonutSummaryProps, + GaugeProps, LegendProps, LineDirectLabelProps, LineProps, @@ -60,6 +61,7 @@ import { getBarOptions } from './barAdapter'; import { getChartPopoverOptions } from './chartPopoverAdapter'; import { getChartTooltipOptions } from './chartTooltipAdapter'; import { getDonutOptions } from './donutAdapter'; +import { getGaugeOptions } from './gaugeAdapter'; import { getLegendOptions } from './legendAdapter'; import { getLineOptions } from './lineAdapter'; @@ -130,6 +132,10 @@ export const childrenToOptions = ( marks.push(getDonutOptions(child.props as DonutProps)); break; + case Gauge.displayName: + marks.push(getGaugeOptions(child.props as GaugeProps)); + break; + case DonutSummary.displayName: donutSummaries.push(child.props as DonutSummaryProps); break; diff --git a/packages/react-spectrum-charts-s2/src/rscToSbAdapter/gaugeAdapter.ts b/packages/react-spectrum-charts-s2/src/rscToSbAdapter/gaugeAdapter.ts new file mode 100644 index 000000000..74ff26eba --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/rscToSbAdapter/gaugeAdapter.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2026 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 } from '@spectrum-charts/vega-spec-builder-s2'; + +import { GaugeProps } from '../types'; +import { childrenToOptions } from './childrenAdapter'; + +export const getGaugeOptions = ({ children, ...gaugeProps }: GaugeProps): GaugeOptions => { + const { chartTooltips } = childrenToOptions(children); + return { + ...gaugeProps, + chartTooltips, + markType: 'gauge', + }; +}; diff --git a/packages/react-spectrum-charts-s2/src/stories/components/Gauge/Gauge.story.tsx b/packages/react-spectrum-charts-s2/src/stories/components/Gauge/Gauge.story.tsx new file mode 100644 index 000000000..1ee8cd289 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/stories/components/Gauge/Gauge.story.tsx @@ -0,0 +1,193 @@ +/* + * Copyright 2026 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'; +import useChartProps from '../../../hooks/useChartProps'; +import { Gauge } from '../../../rc'; +import { bindWithProps } from '../../../test-utils'; +import { ChartProps, GaugeProps } from '../../../types'; +import { gaugeMultiRowData, gaugeWithTargetData } from './data'; + +export default { + title: 'React Spectrum Charts 2/Gauge/Features', + component: Gauge, +}; + +const SIZE = 275; + +const defaultChartProps: ChartProps = { + data: [], + width: SIZE, + height: SIZE, +}; + +const GaugeStory: StoryFn = (args): ReactElement => { + const { width, height, value = 65, ...gaugeProps } = args; + const chartProps = useChartProps({ ...defaultChartProps, data: [{ value }], width: width ?? SIZE, height: height ?? SIZE }); + return ( + + + + ); +}; + +const GaugeAggregationStory: StoryFn = (args): ReactElement => { + const { width, height, ...gaugeProps } = args; + const chartProps = useChartProps({ ...defaultChartProps, data: gaugeMultiRowData, width: width ?? SIZE, height: height ?? SIZE }); + return ( + + + + ); +}; + +const GaugeWithTargetStory: StoryFn = (args): ReactElement => { + const { width, height, value = 65, ...gaugeProps } = args; + const chartProps = useChartProps({ + ...defaultChartProps, + data: [{ ...gaugeWithTargetData[0], value }], + width: width ?? SIZE, + height: height ?? SIZE, + }); + return ( + + + + ); +}; + +// Needle mode (default) +const Basic = bindWithProps(GaugeStory); +Basic.args = { + value: 65, + label: 'Completion Rate', + metric: 'value', + minScaleValue: 0, + maxScaleValue: 100, + showNeedle: true, +}; + +// Fill mode (no needle) +const FillMode = bindWithProps(GaugeStory); +FillMode.args = { + value: 65, + label: 'Completion Rate', + metric: 'value', + minScaleValue: 0, + maxScaleValue: 100, + showNeedle: false, +}; + +// Needle with target marker +const WithTarget = bindWithProps(GaugeWithTargetStory); +WithTarget.args = { + value: 65, + label: 'Completion Rate', + metric: 'value', + target: 'target', + minScaleValue: 0, + maxScaleValue: 100, + showNeedle: true, +}; + +// Threshold zones with needle +const WithThresholds = bindWithProps(GaugeStory); +WithThresholds.args = { + value: 65, + label: 'Completion Rate', + metric: 'value', + minScaleValue: 0, + maxScaleValue: 100, + showNeedle: true, + thresholds: [ + { value: 20, color: 'red-700', label: 'Poor' }, + { value: 70, color: 'gray-300', label: 'Normal' }, + { value: 100, color: 'blue-700', label: 'Good' }, + ], +}; + +// Size variants +const WithSizeS = bindWithProps(GaugeStory); +WithSizeS.args = { value: 65, label: 'Completion Rate', metric: 'value', size: 'S', width: 100, height: 100, ticks: 'normal' }; + +const WithSizeM = bindWithProps(GaugeStory); +WithSizeM.args = { value: 65, label: 'Completion Rate', metric: 'value', size: 'M', width: 200, height: 200, ticks: 'normal' }; + +const WithSizeL = bindWithProps(GaugeStory); +WithSizeL.args = { value: 65, label: 'Completion Rate', metric: 'value', size: 'L', width: 275, height: 275, ticks: 'normal' }; + +const WithSizeXL = bindWithProps(GaugeStory); +WithSizeXL.args = { value: 65, label: 'Completion Rate', metric: 'value', size: 'XL', width: 350, height: 350, ticks: 'normal' }; + +// Tick marks — minimal (6-10 evenly spaced major ticks) +const WithTicksMinimal = bindWithProps(GaugeStory); +WithTicksMinimal.args = { + value: 65, + label: 'Completion Rate', + metric: 'value', + minScaleValue: 0, + maxScaleValue: 100, + showNeedle: true, + ticks: 'minimal', +}; + +// Tick marks — normal (alternating tall/short) +const WithTicksNormal = bindWithProps(GaugeStory); +WithTicksNormal.args = { + value: 65, + label: 'Completion Rate', + metric: 'value', + minScaleValue: 0, + maxScaleValue: 100, + showNeedle: true, + ticks: 'normal', +}; + +// Tick marks — dense (major ticks with 3 minor ticks between each) +const WithTicksDense = bindWithProps(GaugeStory); +WithTicksDense.args = { + value: 65, + label: 'Completion Rate', + metric: 'value', + minScaleValue: 0, + maxScaleValue: 100, + showNeedle: true, + ticks: 'dense', +}; + +// Aggregation method — avg of [40, 60, 80] = 60 +const WithAggregation = bindWithProps(GaugeAggregationStory); +WithAggregation.args = { + label: 'Avg Completion', + metric: 'value', + method: 'avg', + minScaleValue: 0, + maxScaleValue: 100, + showNeedle: true, +}; + +// Range labels +const WithRangeLabels = bindWithProps(GaugeStory); +WithRangeLabels.args = { + value: 65, + label: 'Completion Rate', + metric: 'value', + minScaleValue: 0, + maxScaleValue: 100, + showNeedle: true, + showRangeLabels: true, +}; + +export { Basic, FillMode, WithAggregation, WithRangeLabels, WithSizeL, WithSizeM, WithSizeS, WithSizeXL, WithTarget, WithThresholds, WithTicksDense, WithTicksMinimal, WithTicksNormal }; diff --git a/packages/react-spectrum-charts-s2/src/stories/components/Gauge/data.ts b/packages/react-spectrum-charts-s2/src/stories/components/Gauge/data.ts new file mode 100644 index 000000000..03c60bc13 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/stories/components/Gauge/data.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2026 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 = [{ value: 65 }]; + +export const gaugeWithTargetData = [{ value: 65, target: 80, targetLabel: 'Goal' }]; + +export const gaugeMultiRowData = [{ value: 40 }, { value: 60 }, { value: 80 }]; diff --git a/packages/react-spectrum-charts-s2/src/types/chart.types.ts b/packages/react-spectrum-charts-s2/src/types/chart.types.ts index 0a386d18b..c2f2a0b1b 100644 --- a/packages/react-spectrum-charts-s2/src/types/chart.types.ts +++ b/packages/react-spectrum-charts-s2/src/types/chart.types.ts @@ -28,11 +28,11 @@ import { import { AxisElement } from './axis'; import { ChartPopoverElement, ChartTooltipElement } from './dialogs'; import { LegendElement } from './legend.types'; -import { BarAnnotationElement, BarElement, DonutElement, DonutSummaryElement, LineElement } from './marks'; +import { BarAnnotationElement, BarElement, DonutElement, DonutSummaryElement, GaugeElement, LineElement } from './marks'; import { TitleElement } from './title.types'; import { Children } from './util.types'; -export type ChartChildElement = AxisElement | BarElement | DonutElement | LegendElement | LineElement | TitleElement; +export type ChartChildElement = AxisElement | BarElement | DonutElement | GaugeElement | LegendElement | LineElement | TitleElement; export type MarkChildElement = BarAnnotationElement | ChartPopoverElement | ChartTooltipElement | DonutSummaryElement; export interface SharedChartProps extends Omit { diff --git a/packages/react-spectrum-charts-s2/src/types/marks/gauge.types.ts b/packages/react-spectrum-charts-s2/src/types/marks/gauge.types.ts new file mode 100644 index 000000000..ee6c85861 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/types/marks/gauge.types.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2026 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-s2'; + +import { ChartTooltipElement } from '../dialogs'; +import { Children } from '../util.types'; + +export interface GaugeProps extends Omit { + children?: Children; +} + +export type GaugeElement = ReactElement>; diff --git a/packages/react-spectrum-charts-s2/src/types/marks/index.ts b/packages/react-spectrum-charts-s2/src/types/marks/index.ts index e18972625..8f26853ea 100644 --- a/packages/react-spectrum-charts-s2/src/types/marks/index.ts +++ b/packages/react-spectrum-charts-s2/src/types/marks/index.ts @@ -12,6 +12,7 @@ export * from './bar.types'; export * from './donut.types'; +export * from './gauge.types'; export * from './line.types'; export * from './supplemental'; diff --git a/packages/react-spectrum-charts-s2/src/utils/utils.ts b/packages/react-spectrum-charts-s2/src/utils/utils.ts index c51e3eb9b..fe70687ec 100644 --- a/packages/react-spectrum-charts-s2/src/utils/utils.ts +++ b/packages/react-spectrum-charts-s2/src/utils/utils.ts @@ -38,7 +38,7 @@ import { ReferenceLine, Title, } from '../components'; -import { Donut, DonutSummary, SegmentLabel } from '../rc'; +import { Donut, DonutSummary, Gauge, SegmentLabel } from '../rc'; import { AxisChildElement, AxisElement, @@ -51,6 +51,7 @@ import { ChildElement, DonutElement, DonutSummaryElement, + GaugeElement, LegendElement, LineDirectLabelElement, LineElement, @@ -70,6 +71,7 @@ type RscElement = | AxisElement | BarElement | DonutElement + | GaugeElement | LegendElement | LineElement | TitleElement; @@ -114,6 +116,7 @@ export const sanitizeChildren = (children: unknown): (ChartChildElement | MarkCh ChartTooltip.displayName, Donut.displayName, DonutSummary.displayName, + Gauge.displayName, Legend.displayName, Line.displayName, LineDirectLabel.displayName, @@ -134,6 +137,7 @@ export const sanitizeRscChartChildren = (children: unknown): ChartChildElement[] Axis.displayName, Bar.displayName, Donut.displayName, + Gauge.displayName, Legend.displayName, Line.displayName, Title.displayName, diff --git a/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts b/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts index 6ea14d280..fa252d2ff 100644 --- a/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts +++ b/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts @@ -46,6 +46,7 @@ import { addBullet } from './bullet/bulletSpecBuilder'; import { addCombo } from './combo/comboSpecBuilder'; import { getSeriesIdTransform } from './data/dataUtils'; import { addDonut } from './donut/donutSpecBuilder'; +import { addGauge } from './gauge/gaugeSpecBuilder'; import { setHoverOpacityForMarks } from './legend/legendHighlightUtils'; import { addLegend } from './legend/legendSpecBuilder'; import { addLine } from './line/lineSpecBuilder'; @@ -130,7 +131,7 @@ 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 } = + let { areaCount, barCount, bulletCount, comboCount, donutCount, gaugeCount, lineCount, scatterCount, vennCount } = initializeComponentCounts(); const specOptions = { backgroundColor, colorScheme, idKey, highlightedItem }; spec = [...marks].reduce((acc: ScSpec, mark) => { @@ -150,6 +151,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,9 +221,10 @@ const initializeComponentCounts = () => { return { areaCount: -1, barCount: -1, + bulletCount: -1, comboCount: -1, donutCount: -1, - bulletCount: -1, + gaugeCount: -1, lineCount: -1, scatterCount: -1, vennCount: -1, diff --git a/packages/vega-spec-builder-s2/src/gauge/gaugeMarkUtils.ts b/packages/vega-spec-builder-s2/src/gauge/gaugeMarkUtils.ts new file mode 100644 index 000000000..891d6e318 --- /dev/null +++ b/packages/vega-spec-builder-s2/src/gauge/gaugeMarkUtils.ts @@ -0,0 +1,400 @@ +/* + * Copyright 2026 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 { ArcMark, ColorValueRef, Mark, PathMark, RuleMark, SymbolMark, TextMark } from 'vega'; + +import { getS2ColorValue } from '@spectrum-charts/themes'; + +import { ColorScheme, GaugeSpecOptions, GaugeThreshold } from '../types'; + +const wrapLabel = (text: string | undefined, maxChars: number): string => { + if (!text) return ''; + const words = text.split(' '); + const lines: string[] = []; + let current = ''; + for (const word of words) { + const next = current ? `${current} ${word}` : word; + if (next.length > maxChars && current) { + lines.push(current); + current = word; + } else { + current = next; + } + } + if (current) lines.push(current); + return lines.join('\n'); +}; + +const getGaugeFillColorValue = (color: string, colorScheme: ColorScheme): ColorValueRef => { + const match = color.match(/^categorical-(\d+)$/); + if (match) { + const idx = parseInt(match[1], 10) - 1; + return { signal: `colors[${idx}][0]` }; + } + return { value: getS2ColorValue(color, colorScheme) }; +}; + + +export const getGaugeMarks = (options: GaugeSpecOptions): Mark[] => { + const { showNeedle, target, showRangeLabels, ticks, thresholds } = options; + const marks: Mark[] = [getTrackArcMark(options)]; + + if (thresholds && thresholds.length > 0) { + marks.push(...getThresholdArcMarks(options)); + } else if (!showNeedle) { + marks.push(getValueFillMark(options), getValueFillStartCapMark(options)); + } + + if (ticks) { + marks.push(getTicksMark(options)); + } + + if (showNeedle) { + marks.push(getNeedleMark(options), getNeedleTipMark(options), getPivotMark(options)); + } + + if (target) { + marks.push(getTargetTickMark(options)); + } + + marks.push(getValueLabelMark(options), getMetricLabelMark(options)); + + if (showRangeLabels) { + marks.push(...getRangeLabelMarks(options)); + } + + return marks; +}; + +const getTicksMark = ({ name, colorScheme, tickStrokeWidth }: GaugeSpecOptions): RuleMark => ({ + type: 'rule', + from: { data: `${name}_ticks` }, + encode: { + enter: { + x: { signal: `${name}_cx + (${name}_innerRadius - 6) * sin(datum.angle)` }, + y: { signal: `${name}_cy - (${name}_innerRadius - 6) * cos(datum.angle)` }, + x2: { signal: `${name}_cx + (${name}_innerRadius - 6 - datum.length) * sin(datum.angle)` }, + y2: { signal: `${name}_cy - (${name}_innerRadius - 6 - datum.length) * cos(datum.angle)` }, + stroke: { value: getS2ColorValue('gray-300', colorScheme) }, + strokeWidth: { value: tickStrokeWidth }, + strokeCap: { value: 'round' }, + }, + }, +}); + +const getTrackArcMark = ({ name, colorScheme }: GaugeSpecOptions): ArcMark => ({ + type: 'arc', + encode: { + enter: { + x: { signal: `${name}_cx` }, + y: { signal: `${name}_cy` }, + startAngle: { signal: `${name}_startAngle` }, + endAngle: { signal: `${name}_endAngle` }, + outerRadius: { signal: `${name}_radius` }, + innerRadius: { signal: `${name}_innerRadius` }, + fill: { value: getS2ColorValue('gray-200', colorScheme) }, + cornerRadius: { signal: `(${name}_radius - ${name}_innerRadius) / 2` }, + }, + }, +}); + +const getValueFillMark = ({ name, color, colorScheme }: GaugeSpecOptions): ArcMark => { + const sa = `${name}_startAngle`; + const ea = `${name}_endAngle`; + const capOffset = `${name}_capAngleOffset`; + const totalAngle = `(${ea} - ${sa})`; + const fillEndAngle = `(${sa} + ${capOffset} + (datum.${name}_valueAngle - ${sa}) * (${totalAngle} - 2 * ${capOffset}) / ${totalAngle})`; + return { + type: 'arc', + from: { data: name }, + encode: { + enter: { + x: { signal: `${name}_cx` }, + y: { signal: `${name}_cy` }, + startAngle: { signal: `(${sa} + ${capOffset})` }, + endAngle: { signal: fillEndAngle }, + outerRadius: { signal: `${name}_radius` }, + innerRadius: { signal: `${name}_innerRadius` }, + fill: getGaugeFillColorValue(color, colorScheme), + cornerRadius: { value: 0 }, + }, + }, + }; +}; + +const getThresholdArcMarks = (options: GaugeSpecOptions): Mark[] => { + const { name, thresholds, minScaleValue, maxScaleValue, colorScheme } = options; + if (!thresholds) return []; + const range = maxScaleValue - minScaleValue; + const sa = `${name}_startAngle`; + const ea = `${name}_endAngle`; + const capOffset = `${name}_capAngleOffset`; + const usableAngle = `(${ea} - ${sa} - 2 * ${capOffset})`; + const angleForFraction = (f: number) => `(${sa} + ${capOffset} + ${f} * ${usableAngle})`; + const result: Mark[] = []; + + thresholds.forEach((threshold: GaugeThreshold, i: number) => { + const prevValue = i === 0 ? minScaleValue : thresholds[i - 1].value; + const startFraction = (prevValue - minScaleValue) / range; + const endFraction = (threshold.value - minScaleValue) / range; + const arcStart = i === 0 ? `(${sa} + ${capOffset})` : angleForFraction(startFraction); + const arcEnd = i === thresholds.length - 1 ? `(${ea} - ${capOffset})` : angleForFraction(endFraction); + result.push({ + type: 'arc', + encode: { + enter: { + x: { signal: `${name}_cx` }, + y: { signal: `${name}_cy` }, + startAngle: { signal: arcStart }, + endAngle: { signal: arcEnd }, + outerRadius: { signal: `${name}_radius` }, + innerRadius: { signal: `${name}_innerRadius` }, + fill: { value: getS2ColorValue(threshold.color, colorScheme) }, + cornerRadius: { value: 0 }, + }, + }, + }); + }); + + result.push( + getThresholdStartCapMark(options), + getThresholdEndCapMark(options) + ); + + return result; +}; + +const buildCapPath = (name: string, capAngle: string, sweep: 0 | 1): string => { + const cx = `${name}_cx`; + const cy = `${name}_cy`; + const outerR = `${name}_radius`; + const innerR = `${name}_innerRadius`; + const r = `((${outerR} - ${innerR}) / 2)`; + const p1x = `(${cx} + ${outerR} * sin(${capAngle}))`; + const p1y = `(${cy} - ${outerR} * cos(${capAngle}))`; + const p2x = `(${cx} + ${innerR} * sin(${capAngle}))`; + const p2y = `(${cy} - ${innerR} * cos(${capAngle}))`; + return [ + `'M ' + ${p1x} + ',' + ${p1y}`, + `+ ' A ' + ${r} + ',' + ${r} + ' 0 0 ${sweep} ' + ${p2x} + ',' + ${p2y}`, + `+ ' Z'`, + ].join(' '); +}; + +const getThresholdStartCapMark = ({ name, thresholds = [], colorScheme }: GaugeSpecOptions): PathMark => ({ + type: 'path', + encode: { + enter: { + path: { signal: buildCapPath(name, `(${name}_startAngle + ${name}_capAngleOffset)`, 0) }, + fill: { value: getS2ColorValue(thresholds[0].color, colorScheme) }, + }, + }, +}); + +const getThresholdEndCapMark = ({ name, thresholds = [], colorScheme }: GaugeSpecOptions): PathMark => ({ + type: 'path', + encode: { + enter: { + path: { signal: buildCapPath(name, `(${name}_endAngle - ${name}_capAngleOffset)`, 1) }, + fill: { value: getS2ColorValue(thresholds[thresholds.length - 1].color, colorScheme) }, + }, + }, +}); + + +const getValueFillStartCapMark = ({ name, color, colorScheme }: GaugeSpecOptions): PathMark => ({ + type: 'path', + from: { data: name }, + encode: { + enter: { + path: { signal: buildCapPath(name, `(${name}_startAngle + ${name}_capAngleOffset)`, 0) }, + fill: getGaugeFillColorValue(color, colorScheme), + }, + }, +}); + +const getNeedleMark = ({ name, colorScheme, needleBaseHalfWidth, needleTipHalfWidth, needleTipGap }: GaugeSpecOptions): PathMark => { + const cx = `${name}_cx`; + const cy = `${name}_cy`; + const innerR = `${name}_innerRadius`; + const angle = `datum.${name}_valueAngle`; + const tipR = `(${innerR} - ${needleTipGap})`; + + const bLx = `(${cx} - ${needleBaseHalfWidth}*cos(${angle}))`; + const bLy = `(${cy} - ${needleBaseHalfWidth}*sin(${angle}))`; + const bRx = `(${cx} + ${needleBaseHalfWidth}*cos(${angle}))`; + const bRy = `(${cy} + ${needleBaseHalfWidth}*sin(${angle}))`; + + const tLx = `(${cx} + ${tipR}*sin(${angle}) - ${needleTipHalfWidth}*cos(${angle}))`; + const tLy = `(${cy} - ${tipR}*cos(${angle}) - ${needleTipHalfWidth}*sin(${angle}))`; + const tRx = `(${cx} + ${tipR}*sin(${angle}) + ${needleTipHalfWidth}*cos(${angle}))`; + const tRy = `(${cy} - ${tipR}*cos(${angle}) + ${needleTipHalfWidth}*sin(${angle}))`; + + const pathSignal = [ + `'M ' + ${bLx} + ',' + ${bLy}`, + `+ ' L ' + ${tLx} + ',' + ${tLy}`, + `+ ' L ' + ${tRx} + ',' + ${tRy}`, + `+ ' L ' + ${bRx} + ',' + ${bRy}`, + `+ ' Z'`, + ].join(' '); + + return { + type: 'path', + from: { data: name }, + encode: { + enter: { + path: { signal: pathSignal }, + fill: { value: getS2ColorValue('gray-800', colorScheme) }, + }, + }, + }; +}; + +// Separate circle mark at the tip — guarantees a geometrically perfect round cap +const getNeedleTipMark = ({ name, colorScheme, needleTipDiameter, needleTipGap }: GaugeSpecOptions): SymbolMark => { + const cx = `${name}_cx`; + const cy = `${name}_cy`; + const innerR = `${name}_innerRadius`; + const angle = `datum.${name}_valueAngle`; + const tipR = `(${innerR} - ${needleTipGap})`; + return { + type: 'symbol', + from: { data: name }, + encode: { + enter: { + x: { signal: `${cx} + ${tipR} * sin(${angle})` }, + y: { signal: `${cy} - ${tipR} * cos(${angle})` }, + size: { value: needleTipDiameter * needleTipDiameter }, + shape: { value: 'circle' }, + fill: { value: getS2ColorValue('gray-800', colorScheme) }, + }, + }, + }; +}; + +const getPivotMark = ({ name, colorScheme, pivotDiameter, pivotStrokeWidth }: GaugeSpecOptions): SymbolMark => ({ + type: 'symbol', + from: { data: name }, + zindex: 1, + encode: { + enter: { + x: { signal: `${name}_cx` }, + y: { signal: `${name}_cy` }, + size: { value: pivotDiameter * pivotDiameter }, + fill: { value: '#ffffff' }, + stroke: { value: getS2ColorValue('gray-800', colorScheme) }, + strokeWidth: { value: pivotStrokeWidth }, + shape: { value: 'circle' }, + }, + }, +}); + +const getTargetTickMark = ({ name }: GaugeSpecOptions): RuleMark => ({ + type: 'rule', + from: { data: name }, + encode: { + enter: { + x: { signal: `${name}_cx + (${name}_innerRadius - 4) * sin(datum.${name}_targetAngle)` }, + y: { signal: `${name}_cy - (${name}_innerRadius - 4) * cos(datum.${name}_targetAngle)` }, + x2: { signal: `${name}_cx + (${name}_radius + 4) * sin(datum.${name}_targetAngle)` }, + y2: { signal: `${name}_cy - (${name}_radius + 4) * cos(datum.${name}_targetAngle)` }, + stroke: { value: '#000000' }, + strokeWidth: { value: 4 }, + strokeCap: { value: 'round' }, + }, + }, +}); + +const getValueLabelMark = ({ name, metric, showNeedle, valueFontSize }: GaugeSpecOptions): TextMark => { + const yMultiplier = showNeedle ? '0.43' : '-0.094'; + return { + type: 'text', + from: { data: name }, + encode: { + enter: { + x: { signal: `${name}_cx` }, + y: { signal: `${name}_cy + ${name}_radius * ${yMultiplier}` }, + text: { field: metric }, + align: { value: 'center' }, + baseline: { value: 'middle' }, + fontSize: { value: valueFontSize }, + fontWeight: { value: 800 }, + fontFamily: { value: 'adobe-clean' }, + fill: { value: '#292929' }, + }, + }, + }; +}; + +const getMetricLabelMark = ({ name, label, showNeedle, metricFontSize, metricWrapChars, metricLineHeight }: GaugeSpecOptions): TextMark => { + const yMultiplier = showNeedle ? '0.701' : '0.181'; + return { + type: 'text', + encode: { + enter: { + x: { signal: `${name}_cx` }, + y: { signal: `${name}_cy + ${name}_radius * ${yMultiplier}` }, + text: { value: wrapLabel(label, metricWrapChars) }, + align: { value: 'center' }, + baseline: { value: 'middle' }, + fontSize: { value: metricFontSize }, + fontWeight: { value: 700 }, + fontFamily: { value: 'adobe-clean' }, + fill: { value: '#505050' }, + lineBreak: { value: '\n' }, + lineHeight: { value: metricLineHeight }, + }, + }, + }; +}; + +const getRangeLabelMarks = ({ name, minScaleValue, maxScaleValue, colorScheme }: GaugeSpecOptions): TextMark[] => [ + { + type: 'text', + encode: { + enter: { + x: { + signal: `${name}_cx + (${name}_radius + 12) * sin(${name}_startAngle)`, + }, + y: { + signal: `${name}_cy - (${name}_radius + 12) * cos(${name}_startAngle)`, + }, + text: { value: String(minScaleValue) }, + align: { value: 'center' }, + baseline: { value: 'middle' }, + fontSize: { value: 18 }, + fontWeight: { value: 400 }, + fontFamily: { value: 'adobe-clean' }, + fill: { value: getS2ColorValue('gray-700', colorScheme) }, + }, + }, + }, + { + type: 'text', + encode: { + enter: { + x: { + signal: `${name}_cx + (${name}_radius + 12) * sin(${name}_endAngle)`, + }, + y: { + signal: `${name}_cy - (${name}_radius + 12) * cos(${name}_endAngle)`, + }, + text: { value: String(maxScaleValue) }, + align: { value: 'center' }, + baseline: { value: 'middle' }, + fontSize: { value: 18 }, + fontWeight: { value: 400 }, + fontFamily: { value: 'adobe-clean' }, + fill: { value: getS2ColorValue('gray-700', colorScheme) }, + }, + }, + }, +]; diff --git a/packages/vega-spec-builder-s2/src/gauge/gaugeSpecBuilder.ts b/packages/vega-spec-builder-s2/src/gauge/gaugeSpecBuilder.ts new file mode 100644 index 000000000..552d21465 --- /dev/null +++ b/packages/vega-spec-builder-s2/src/gauge/gaugeSpecBuilder.ts @@ -0,0 +1,323 @@ +/* + * Copyright 2026 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 { AggregateTransform, Data, FilterTransform, FormulaTransform, Mark, Signal, WindowTransform } from 'vega'; + +import { + DEFAULT_COLOR_SCHEME, + DEFAULT_METRIC, + FILTERED_TABLE, +} from '@spectrum-charts/constants'; +import { toCamelCase } from '@spectrum-charts/utils'; + +import { ColorScheme, GaugeOptions, GaugeSpecOptions, HighlightedItem, ScSpec } from '../types'; +import { getGaugeMarks } from './gaugeMarkUtils'; + +const DEFAULT_ARC_SIZE = 2 / 3; +const DEFAULT_HOLE_RATIO = 0.8; +const DEFAULT_MAX_SCALE_VALUE = 100; +const DEFAULT_METHOD = 'last'; +const DEFAULT_MIN_SCALE_VALUE = 0; +const DEFAULT_NUMBER_FORMAT = 'shortNumber'; +const DEFAULT_SIZE = 'L'; + +interface GaugeSizeConfig { + needleValueFontSize: number; + fillValueFontSize: number; + needleMetricFontSize: number; + fillMetricFontSize: number; + needleMetricWrapChars: number; + fillMetricWrapChars: number; + needleMetricLineHeight: number; + fillMetricLineHeight: number; + pivotDiameter: number; + pivotStrokeWidth: number; + needleBaseHalfWidth: number; + needleTipHalfWidth: number; + needleTipDiameter: number; + needleTipGap: number; + tickMajorLength: number; + tickMinorLength: number; + tickStrokeWidth: number; + tickMinimalTarget: number; + tickDenseMajorTarget: number; + tickDenseMinorCount: number; +} + +const GAUGE_SIZE_CONFIG: Record = { + S: { needleValueFontSize: 16, fillValueFontSize: 24, needleMetricFontSize: 12, fillMetricFontSize: 12, needleMetricWrapChars: 10, fillMetricWrapChars: 6, needleMetricLineHeight: 14, fillMetricLineHeight: 14, pivotDiameter: 7, pivotStrokeWidth: 1.5, needleBaseHalfWidth: 4, needleTipHalfWidth: 2, needleTipDiameter: 4, needleTipGap: 4, tickMajorLength: 5, tickMinorLength: 2, tickStrokeWidth: 1.5, tickMinimalTarget: 4, tickDenseMajorTarget: 5, tickDenseMinorCount: 1 }, + M: { needleValueFontSize: 28, fillValueFontSize: 40, needleMetricFontSize: 20, fillMetricFontSize: 22, needleMetricWrapChars: 18, fillMetricWrapChars: 10, needleMetricLineHeight: 22, fillMetricLineHeight: 24, pivotDiameter: 13, pivotStrokeWidth: 2, needleBaseHalfWidth: 7.5, needleTipHalfWidth: 3, needleTipDiameter: 6, needleTipGap: 7, tickMajorLength: 8, tickMinorLength: 3, tickStrokeWidth: 2, tickMinimalTarget: 6, tickDenseMajorTarget: 11, tickDenseMinorCount: 2 }, + L: { needleValueFontSize: 40, fillValueFontSize: 56, needleMetricFontSize: 26, fillMetricFontSize: 32, needleMetricWrapChars: 26, fillMetricWrapChars: 14, needleMetricLineHeight: 24, fillMetricLineHeight: 32, pivotDiameter: 18, pivotStrokeWidth: 3, needleBaseHalfWidth: 10.5, needleTipHalfWidth: 4, needleTipDiameter: 8, needleTipGap: 10, tickMajorLength: 12, tickMinorLength: 5, tickStrokeWidth: 3, tickMinimalTarget: 7, tickDenseMajorTarget: 11, tickDenseMinorCount: 3 }, + XL: { needleValueFontSize: 48, fillValueFontSize: 54, needleMetricFontSize: 26, fillMetricFontSize: 28, needleMetricWrapChars: 28, fillMetricWrapChars: 16, needleMetricLineHeight: 28, fillMetricLineHeight: 30, pivotDiameter: 21, pivotStrokeWidth: 4.5, needleBaseHalfWidth: 12, needleTipHalfWidth: 5, needleTipDiameter: 9, needleTipGap: 12, tickMajorLength: 14, tickMinorLength: 6, tickStrokeWidth: 3, tickMinimalTarget: 10, tickDenseMajorTarget: 11, tickDenseMinorCount: 4 }, +}; + +const resolveSizeConfig = (size: 'XL' | 'L' | 'M' | 'S' | number): GaugeSizeConfig => + typeof size === 'string' ? (GAUGE_SIZE_CONFIG[size] ?? GAUGE_SIZE_CONFIG.L) : GAUGE_SIZE_CONFIG.L; + +export const addGauge = produce< + ScSpec, + [ + GaugeOptions & { + colorScheme?: ColorScheme; + highlightedItem?: HighlightedItem; + index?: number; + } + ] +>( + ( + spec, + { + arcSize = DEFAULT_ARC_SIZE, + chartTooltips = [], + color = 'categorical-01', + colorScheme = DEFAULT_COLOR_SCHEME, + holeRatio = DEFAULT_HOLE_RATIO, + index = 0, + label, + maxScaleValue = DEFAULT_MAX_SCALE_VALUE, + method = DEFAULT_METHOD, + metric = DEFAULT_METRIC, + minScaleValue = DEFAULT_MIN_SCALE_VALUE, + name, + numberFormat = DEFAULT_NUMBER_FORMAT, + showNeedle = true, + showRangeLabels = false, + size = DEFAULT_SIZE, + target, + targetLabel, + thresholds, + ticks, + ...options + } + ) => { + const sc = resolveSizeConfig(size); + const gaugeOptions: GaugeSpecOptions = { + arcSize, + chartTooltips, + color, + colorScheme, + holeRatio: Math.min(0.9, Math.max(0.65, holeRatio)), + index, + label, + maxScaleValue, + method, + metric, + minScaleValue, + name: toCamelCase(name ?? `gauge${index}`), + numberFormat, + showNeedle, + showRangeLabels, + size, + target, + targetLabel, + thresholds, + ticks, + valueFontSize: showNeedle ? sc.needleValueFontSize : sc.fillValueFontSize, + metricFontSize: showNeedle ? sc.needleMetricFontSize : sc.fillMetricFontSize, + metricWrapChars: showNeedle ? sc.needleMetricWrapChars : sc.fillMetricWrapChars, + metricLineHeight: showNeedle ? sc.needleMetricLineHeight : sc.fillMetricLineHeight, + pivotDiameter: sc.pivotDiameter, + pivotStrokeWidth: sc.pivotStrokeWidth, + needleBaseHalfWidth: sc.needleBaseHalfWidth, + needleTipHalfWidth: sc.needleTipHalfWidth, + needleTipDiameter: sc.needleTipDiameter, + needleTipGap: sc.needleTipGap, + tickMajorLength: sc.tickMajorLength, + tickMinorLength: sc.tickMinorLength, + tickStrokeWidth: sc.tickStrokeWidth, + tickMinimalTarget: sc.tickMinimalTarget, + tickDenseMajorTarget: sc.tickDenseMajorTarget, + tickDenseMinorCount: sc.tickDenseMinorCount, + ...options, + }; + + spec.data = addData(spec.data ?? [], gaugeOptions); + spec.signals = addSignals(spec.signals ?? [], gaugeOptions); + spec.marks = addMarks(spec.marks ?? [], gaugeOptions); + } +); + +export const addData = produce((data, options) => { + const { name, metric, minScaleValue, maxScaleValue, method, target, ticks } = options; + const transforms: Data['transform'] = [ + ...getAggregationTransforms(name, metric, method, target), + getValueAngleFormula(name, metric, minScaleValue, maxScaleValue), + ]; + + if (target) { + transforms.push(getTargetAngleFormula(name, target, minScaleValue, maxScaleValue)); + } + + data.push({ + name, + source: FILTERED_TABLE, + transform: transforms, + }); + + if (ticks) { + const range = maxScaleValue - minScaleValue; + const { tickMajorLength, tickMinorLength, tickMinimalTarget, tickDenseMajorTarget, tickDenseMinorCount } = options; + data.push({ + name: `${name}_ticks`, + values: computeTickData(ticks, minScaleValue, maxScaleValue, tickMajorLength, tickMinorLength, tickMinimalTarget, tickDenseMajorTarget, tickDenseMinorCount), + transform: [ + { + type: 'formula', + as: 'angle', + expr: `${name}_startAngle + (datum.value - ${minScaleValue}) / ${range} * (${name}_endAngle - ${name}_startAngle)`, + }, + ], + }); + } +}); + +const niceInterval = (range: number, targetCount: number): number => { + const roughStep = range / Math.max(1, targetCount - 1); + const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep))); + const normalized = roughStep / magnitude; + let multiplier: number; + if (normalized < 1.5) multiplier = 1; + else if (normalized < 3) multiplier = 2; + else if (normalized < 7) multiplier = 5; + else multiplier = 10; + return multiplier * magnitude; +}; + +const linearTicks = (min: number, max: number, step: number): number[] => { + const values: number[] = []; + const first = Math.ceil((min - 1e-10) / step) * step; + for (let v = first; v <= max + 1e-10; v += step) { + values.push(parseFloat(v.toPrecision(10))); + } + return values; +}; + +const computeTickData = ( + mode: 'minimal' | 'normal' | 'dense', + min: number, + max: number, + majorLength: number, + minorLength: number, + minimalTarget: number, + denseMajorTarget: number, + denseMinorCount: number +): Array<{ value: number; length: number }> => { + const range = max - min; + const majorStep = niceInterval(range, minimalTarget); + + if (mode === 'minimal') { + return linearTicks(min, max, majorStep).map(v => ({ value: v, length: majorLength })); + } + + if (mode === 'normal') { + const majors = linearTicks(min, max, majorStep); + const result: { value: number; length: number }[] = []; + for (let i = 0; i < majors.length; i++) { + result.push({ value: majors[i], length: majorLength }); + if (i < majors.length - 1) { + result.push({ value: (majors[i] + majors[i + 1]) / 2, length: minorLength }); + } + } + return result; + } + + // dense + const denseMajorStep = niceInterval(range, denseMajorTarget); + const result: { value: number; length: number }[] = []; + const majors = linearTicks(min, max, denseMajorStep); + for (let i = 0; i < majors.length; i++) { + result.push({ value: majors[i], length: majorLength }); + if (i < majors.length - 1) { + const subStep = (majors[i + 1] - majors[i]) / (denseMinorCount + 1); + for (let j = 1; j <= denseMinorCount; j++) { + result.push({ + value: parseFloat((majors[i] + j * subStep).toPrecision(10)), + length: minorLength, + }); + } + } + } + return result; +}; + +const getAggregationTransforms = ( + name: string, + metric: string, + method: string, + target?: string +): (WindowTransform | FilterTransform | AggregateTransform)[] => { + if (method === 'avg' || method === 'sum') { + const op = method === 'avg' ? 'mean' : 'sum'; + const fields = [metric, ...(target ? [target] : [])]; + return [ + { + type: 'aggregate', + fields, + ops: fields.map(() => op), + as: fields, + } as AggregateTransform, + ]; + } + return [ + { type: 'window', ops: ['row_number'], as: [`${name}_rowIndex`] } as WindowTransform, + { type: 'filter', expr: `datum.${name}_rowIndex === 1` } as FilterTransform, + ]; +}; + +const getValueAngleFormula = ( + name: string, + metric: string, + minScaleValue: number, + maxScaleValue: number +): FormulaTransform => ({ + type: 'formula', + as: `${name}_valueAngle`, + expr: [ + `${name}_startAngle`, + `+ (clamp(datum['${metric}'], ${minScaleValue}, ${maxScaleValue}) - ${minScaleValue})`, + `/ (${maxScaleValue} - ${minScaleValue})`, + `* (${name}_endAngle - ${name}_startAngle)`, + ].join(' '), +}); + +const getTargetAngleFormula = ( + name: string, + target: string, + minScaleValue: number, + maxScaleValue: number +): FormulaTransform => ({ + type: 'formula', + as: `${name}_targetAngle`, + expr: [ + `${name}_startAngle`, + `+ (clamp(datum['${target}'], ${minScaleValue}, ${maxScaleValue}) - ${minScaleValue})`, + `/ (${maxScaleValue} - ${minScaleValue})`, + `* (${name}_endAngle - ${name}_startAngle)`, + ].join(' '), +}); + +export const addSignals = produce((signals, { name, arcSize, holeRatio }) => { + signals.push( + { name: `${name}_cx`, update: 'width / 2' }, + { name: `${name}_cy`, update: 'height * 0.62' }, + { name: `${name}_radius`, update: 'min(width / 2 - 4, height * 0.62 * 0.82)' }, + { name: `${name}_innerRadius`, update: `${name}_radius * ${holeRatio}` }, + { name: `${name}_capAngleOffset`, update: `asin((${name}_radius - ${name}_innerRadius) / 2 / ((${name}_radius + ${name}_innerRadius) / 2))` }, + { name: `${name}_totalAngle`, update: `${arcSize} * 2 * PI` }, + { name: `${name}_startAngle`, update: `-${name}_totalAngle / 2` }, + { name: `${name}_endAngle`, update: `${name}_totalAngle / 2` } + ); +}); + +export const addMarks = produce((marks, options) => { + marks.push(...getGaugeMarks(options)); +}); diff --git a/packages/vega-spec-builder-s2/src/types/chartSpec.types.ts b/packages/vega-spec-builder-s2/src/types/chartSpec.types.ts index 292d34733..4661c1d1c 100644 --- a/packages/vega-spec-builder-s2/src/types/chartSpec.types.ts +++ b/packages/vega-spec-builder-s2/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-s2/src/types/marks/gaugeSpec.types.ts b/packages/vega-spec-builder-s2/src/types/marks/gaugeSpec.types.ts new file mode 100644 index 000000000..1294824df --- /dev/null +++ b/packages/vega-spec-builder-s2/src/types/marks/gaugeSpec.types.ts @@ -0,0 +1,103 @@ +/* + * Copyright 2026 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 { ChartTooltipOptions } from '../dialogs/chartTooltipSpec.types'; +import { NumberFormat, PartiallyRequired } from '../specUtil.types'; + +export interface GaugeThreshold { + /** Value at which this zone ends. The zone begins at the previous threshold's value (or minScaleValue). */ + value: number; + /** Spectrum color token for this zone (e.g. 'red-600', 'blue-500'). */ + color: string; + /** Optional label displayed at this breakpoint on the arc. */ + label?: string; +} + +export interface GaugeOptions { + markType: 'gauge'; + + /** Component identifier. Follows standard RSC mark convention. */ + name?: string; + /** Metric name displayed below the value in the center of the gauge. Always shown. */ + label: string; + /** Data key for the current value. */ + metric?: string; + /** Minimum value for the scale range. */ + minScaleValue?: number; + /** Maximum value for the scale range. */ + maxScaleValue?: number; + /** How to aggregate the metric when the dataset has multiple rows. */ + method?: 'last' | 'avg' | 'sum'; + /** d3 number format specifier for the displayed value. */ + numberFormat?: NumberFormat; + /** When true, a needle pointer indicates the value. When false, the arc fills to the value position. */ + showNeedle?: boolean; + /** Spectrum color token for the fill or needle. Ignored when thresholds is set. */ + color?: string; + /** Fraction of a full circle the arc spans. Clamped to 0.2–0.85. Default 0.667. */ + arcSize?: number; + /** Inner radius as a fraction of outer radius (controls track thickness). Clamped to 0.65–0.9. Default 0.8. */ + holeRatio?: number; + /** Sequential performance zone breakpoints. */ + thresholds?: GaugeThreshold[]; + /** Data key for the target/goal value shown as a tick on the arc. */ + target?: string; + /** Data key for a custom label displayed alongside the target marker. */ + targetLabel?: string; + /** Decorative tick marks rendered inside the arc. */ + ticks?: 'minimal' | 'normal' | 'dense'; + /** Show minScaleValue and maxScaleValue labels at the arc endpoints. */ + showRangeLabels?: boolean; + /** Overall size of the gauge. Controls typography. Pair with matching container dimensions: S=100px, M=200px, L=300px, XL=350px. */ + size?: 'XL' | 'L' | 'M' | 'S' | number; + + // children + chartTooltips?: ChartTooltipOptions[]; +} + +type GaugeOptionsWithDefaults = + | 'arcSize' + | 'chartTooltips' + | 'color' + | 'holeRatio' + | 'maxScaleValue' + | 'method' + | 'metric' + | 'minScaleValue' + | 'name' + | 'numberFormat' + | 'showNeedle' + | 'showRangeLabels' + | 'size'; + +export interface GaugeSpecOptions extends PartiallyRequired { + colorScheme: ColorScheme; + index: number; + markType: 'gauge'; + // Resolved typography and needle dimensions from the size prop + valueFontSize: number; + metricFontSize: number; + metricWrapChars: number; + metricLineHeight: number; + pivotDiameter: number; + pivotStrokeWidth: number; + needleBaseHalfWidth: number; + needleTipHalfWidth: number; + needleTipDiameter: number; + needleTipGap: number; + tickMajorLength: number; + tickMinorLength: number; + tickStrokeWidth: number; + tickMinimalTarget: number; + tickDenseMajorTarget: number; + tickDenseMinorCount: number; +} diff --git a/packages/vega-spec-builder-s2/src/types/marks/index.ts b/packages/vega-spec-builder-s2/src/types/marks/index.ts index b9beef2fc..dbfb834cd 100644 --- a/packages/vega-spec-builder-s2/src/types/marks/index.ts +++ b/packages/vega-spec-builder-s2/src/types/marks/index.ts @@ -16,6 +16,7 @@ export * from './bigNumberSpec.types'; export * from './bulletSpec.types'; export * from './comboSpec.types'; export * from './donutSpec.types'; +export * from './gaugeSpec.types'; export * from './lineSpec.types'; export * from './scatterSpec.types'; export * from './vennSpec.types';