From 0cbde33cc03011686e2a6526340978b3fbc9b426 Mon Sep 17 00:00:00 2001 From: Alan Wilson Date: Tue, 21 Apr 2026 14:56:07 -0600 Subject: [PATCH 1/2] feat(s2): add Gauge chart mark Implements the full Gauge chart component for the react-spectrum-charts-s2 package, including needle mode, fill mode, threshold zones, target tick marker, range labels, and value/metric labels. Co-Authored-By: Claude Sonnet 4.6 --- .../src/rc/components/Gauge/Gauge.tsx | 55 +++ .../src/rc/components/Gauge/index.ts | 13 + .../src/rc/components/index.ts | 1 + .../src/rscToSbAdapter/childrenAdapter.ts | 8 +- .../src/rscToSbAdapter/gaugeAdapter.ts | 24 ++ .../stories/components/Gauge/Gauge.story.tsx | 123 ++++++ .../src/stories/components/Gauge/data.ts | 15 + .../src/types/chart.types.ts | 4 +- .../src/types/marks/gauge.types.ts | 23 ++ .../src/types/marks/index.ts | 1 + .../src/utils/utils.ts | 6 +- .../src/chartSpecBuilder.ts | 9 +- .../src/gauge/gaugeMarkUtils.ts | 375 ++++++++++++++++++ .../src/gauge/gaugeSpecBuilder.ts | 172 ++++++++ .../src/types/chartSpec.types.ts | 2 + .../src/types/marks/gaugeSpec.types.ts | 86 ++++ .../src/types/marks/index.ts | 1 + 17 files changed, 912 insertions(+), 6 deletions(-) create mode 100644 packages/react-spectrum-charts-s2/src/rc/components/Gauge/Gauge.tsx create mode 100644 packages/react-spectrum-charts-s2/src/rc/components/Gauge/index.ts create mode 100644 packages/react-spectrum-charts-s2/src/rscToSbAdapter/gaugeAdapter.ts create mode 100644 packages/react-spectrum-charts-s2/src/stories/components/Gauge/Gauge.story.tsx create mode 100644 packages/react-spectrum-charts-s2/src/stories/components/Gauge/data.ts create mode 100644 packages/react-spectrum-charts-s2/src/types/marks/gauge.types.ts create mode 100644 packages/vega-spec-builder-s2/src/gauge/gaugeMarkUtils.ts create mode 100644 packages/vega-spec-builder-s2/src/gauge/gaugeSpecBuilder.ts create mode 100644 packages/vega-spec-builder-s2/src/types/marks/gaugeSpec.types.ts 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..313a6d8e8 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/stories/components/Gauge/Gauge.story.tsx @@ -0,0 +1,123 @@ +/* + * 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 { gaugeWithTargetData } from './data'; + +export default { + title: 'React Spectrum Charts 2/Gauge/Features', + component: Gauge, +}; + +const SIZE = 300; + +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 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' }, + ], +}; + +// 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, WithRangeLabels, WithTarget, WithThresholds }; 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..74075977c --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/stories/components/Gauge/data.ts @@ -0,0 +1,15 @@ +/* + * 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' }]; 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..73aae816f --- /dev/null +++ b/packages/vega-spec-builder-s2/src/gauge/gaugeMarkUtils.ts @@ -0,0 +1,375 @@ +/* + * 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 } 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) }; +}; + +import { GaugeSpecOptions, GaugeThreshold } from '../types'; + +export const getGaugeMarks = (options: GaugeSpecOptions): Mark[] => { + const { showNeedle, target, showRangeLabels, 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 (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 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 }: GaugeSpecOptions): PathMark => { + const cx = `${name}_cx`; + const cy = `${name}_cy`; + const innerR = `${name}_innerRadius`; + const angle = `datum.${name}_valueAngle`; + // Tip shoulders end 12px before the inner track edge + const tipR = `(${innerR} - 12)`; + + // Base: ±10.5px perpendicular (21px total width at pivot end) + const bLx = `(${cx} - 10.5*cos(${angle}))`; + const bLy = `(${cy} - 10.5*sin(${angle}))`; + const bRx = `(${cx} + 10.5*cos(${angle}))`; + const bRy = `(${cy} + 10.5*sin(${angle}))`; + + // Tip shoulders: ±4px wide + const tLx = `(${cx} + ${tipR}*sin(${angle}) - 4*cos(${angle}))`; + const tLy = `(${cy} - ${tipR}*cos(${angle}) - 4*sin(${angle}))`; + const tRx = `(${cx} + ${tipR}*sin(${angle}) + 4*cos(${angle}))`; + const tRy = `(${cy} - ${tipR}*cos(${angle}) + 4*sin(${angle}))`; + + // Straight taper — rounding is handled by a separate tip circle mark + 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 }: GaugeSpecOptions): SymbolMark => { + const cx = `${name}_cx`; + const cy = `${name}_cy`; + const innerR = `${name}_innerRadius`; + const angle = `datum.${name}_valueAngle`; + const tipR = `(${innerR} - 12)`; + return { + type: 'symbol', + from: { data: name }, + encode: { + enter: { + x: { signal: `${cx} + ${tipR} * sin(${angle})` }, + y: { signal: `${cy} - ${tipR} * cos(${angle})` }, + size: { value: 64 }, + shape: { value: 'circle' }, + fill: { value: getS2ColorValue('gray-800', colorScheme) }, + }, + }, + }; +}; + +const getPivotMark = ({ name, colorScheme }: GaugeSpecOptions): SymbolMark => ({ + type: 'symbol', + from: { data: name }, + zindex: 1, + encode: { + enter: { + x: { signal: `${name}_cx` }, + y: { signal: `${name}_cy` }, + size: { value: 324 }, + fill: { value: '#ffffff' }, + stroke: { value: getS2ColorValue('gray-800', colorScheme) }, + strokeWidth: { value: 3 }, + shape: { value: 'circle' }, + }, + }, +}); + +const getTargetTickMark = ({ name, target, colorScheme }: 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 }: GaugeSpecOptions): TextMark => ({ + type: 'text', + from: { data: name }, + encode: { + enter: { + x: { signal: `${name}_cx` }, + y: { signal: `${name}_cy + ${name}_radius * 0.18${showNeedle ? ' + 36' : ' - 40'}` }, + text: { field: metric }, + align: { value: 'center' }, + baseline: { value: 'middle' }, + fontSize: { value: showNeedle ? 40 : 56 }, + fontWeight: { value: 800 }, + fontFamily: { value: 'adobe-clean' }, + fill: { value: '#292929' }, + }, + }, +}); + +const getMetricLabelMark = ({ name, label, showNeedle }: GaugeSpecOptions): TextMark => ({ + type: 'text', + encode: { + enter: { + x: { signal: `${name}_cx` }, + y: { signal: `${name}_cy + ${name}_radius * 0.4 + 8${showNeedle ? ' + 36' : ' - 40'}` }, + text: { value: wrapLabel(label, showNeedle ? 26 : 14) }, + align: { value: 'center' }, + baseline: { value: 'middle' }, + fontSize: { value: showNeedle ? 26 : 32 }, + fontWeight: { value: 700 }, + fontFamily: { value: 'adobe-clean' }, + fill: { value: '#505050' }, + lineBreak: { value: '\n' }, + lineHeight: { value: showNeedle ? 24 : 32 }, + }, + }, +}); + +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: 11 }, + fill: { value: getS2ColorValue('gray-600', 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: 11 }, + fill: { value: getS2ColorValue('gray-600', 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..1b8df2059 --- /dev/null +++ b/packages/vega-spec-builder-s2/src/gauge/gaugeSpecBuilder.ts @@ -0,0 +1,172 @@ +/* + * 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 { Data, FormulaTransform, Mark, Signal } 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 = 'M'; + +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 gaugeOptions: GaugeSpecOptions = { + arcSize, + chartTooltips, + color, + colorScheme, + holeRatio, + index, + label, + maxScaleValue, + method, + metric, + minScaleValue, + name: toCamelCase(name ?? `gauge${index}`), + numberFormat, + showNeedle, + showRangeLabels, + size, + target, + targetLabel, + thresholds, + ticks, + ...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, target } = options; + const transforms: Data['transform'] = [ + { + type: 'window', + ops: ['row_number'], + as: [`${name}_rowIndex`], + }, + { + type: 'filter', + expr: `datum.${name}_rowIndex === 1`, + }, + getValueAngleFormula(name, metric, minScaleValue, maxScaleValue), + ]; + + if (target) { + transforms.push(getTargetAngleFormula(name, target, minScaleValue, maxScaleValue)); + } + + data.push({ + name, + source: FILTERED_TABLE, + transform: transforms, + }); +}); + +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..8dc0ef79d --- /dev/null +++ b/packages/vega-spec-builder-s2/src/types/marks/gaugeSpec.types.ts @@ -0,0 +1,86 @@ +/* + * 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.4–0.9. */ + 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. Named: XL=350px, L=225px, M=150px, S=110px. */ + 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'; +} 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'; From 1877a268e74925624b28d20b3bde6fe2b92b5461 Mon Sep 17 00:00:00 2001 From: Alan Wilson Date: Tue, 21 Apr 2026 17:40:01 -0600 Subject: [PATCH 2/2] =?UTF-8?q?feat(s2):=20refine=20Gauge=20chart=20?= =?UTF-8?q?=E2=80=94=20sizes,=20ticks,=20needle=20scaling,=20aggregation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add S/M/L/XL size system with per-size typography and needle dimensions - Scale needle pivot, tip gap, base/tip half-width across sizes - Add tick marks (minimal/normal/dense) rendered inside inner radius - Scale tick length and density per size via GAUGE_SIZE_CONFIG lookup - Add avg/sum aggregation methods alongside existing last - Clamp holeRatio to 0.65–0.9 - Convert label Y positions to radius fractions for size-independence - Update range label styling to Adobe Clean Regular 18px gray-700 - Add Storybook stories for aggregation, ticks, and all four sizes Co-Authored-By: Claude Sonnet 4.6 --- .../stories/components/Gauge/Gauge.story.tsx | 76 +++++++- .../src/stories/components/Gauge/data.ts | 2 + .../src/gauge/gaugeMarkUtils.ts | 161 +++++++++------- .../src/gauge/gaugeSpecBuilder.ts | 177 ++++++++++++++++-- .../src/types/marks/gaugeSpec.types.ts | 21 ++- 5 files changed, 351 insertions(+), 86 deletions(-) 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 index 313a6d8e8..1ee8cd289 100644 --- 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 @@ -18,14 +18,14 @@ import useChartProps from '../../../hooks/useChartProps'; import { Gauge } from '../../../rc'; import { bindWithProps } from '../../../test-utils'; import { ChartProps, GaugeProps } from '../../../types'; -import { gaugeWithTargetData } from './data'; +import { gaugeMultiRowData, gaugeWithTargetData } from './data'; export default { title: 'React Spectrum Charts 2/Gauge/Features', component: Gauge, }; -const SIZE = 300; +const SIZE = 275; const defaultChartProps: ChartProps = { data: [], @@ -43,6 +43,16 @@ const GaugeStory: 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({ @@ -108,6 +118,66 @@ WithThresholds.args = { ], }; +// 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 = { @@ -120,4 +190,4 @@ WithRangeLabels.args = { showRangeLabels: true, }; -export { Basic, FillMode, WithRangeLabels, WithTarget, WithThresholds }; +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 index 74075977c..03c60bc13 100644 --- a/packages/react-spectrum-charts-s2/src/stories/components/Gauge/data.ts +++ b/packages/react-spectrum-charts-s2/src/stories/components/Gauge/data.ts @@ -13,3 +13,5 @@ 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/vega-spec-builder-s2/src/gauge/gaugeMarkUtils.ts b/packages/vega-spec-builder-s2/src/gauge/gaugeMarkUtils.ts index 73aae816f..891d6e318 100644 --- a/packages/vega-spec-builder-s2/src/gauge/gaugeMarkUtils.ts +++ b/packages/vega-spec-builder-s2/src/gauge/gaugeMarkUtils.ts @@ -13,7 +13,7 @@ import { ArcMark, ColorValueRef, Mark, PathMark, RuleMark, SymbolMark, TextMark import { getS2ColorValue } from '@spectrum-charts/themes'; -import { ColorScheme } from '../types'; +import { ColorScheme, GaugeSpecOptions, GaugeThreshold } from '../types'; const wrapLabel = (text: string | undefined, maxChars: number): string => { if (!text) return ''; @@ -42,10 +42,9 @@ const getGaugeFillColorValue = (color: string, colorScheme: ColorScheme): ColorV return { value: getS2ColorValue(color, colorScheme) }; }; -import { GaugeSpecOptions, GaugeThreshold } from '../types'; export const getGaugeMarks = (options: GaugeSpecOptions): Mark[] => { - const { showNeedle, target, showRangeLabels, thresholds } = options; + const { showNeedle, target, showRangeLabels, ticks, thresholds } = options; const marks: Mark[] = [getTrackArcMark(options)]; if (thresholds && thresholds.length > 0) { @@ -54,6 +53,10 @@ export const getGaugeMarks = (options: GaugeSpecOptions): Mark[] => { marks.push(getValueFillMark(options), getValueFillStartCapMark(options)); } + if (ticks) { + marks.push(getTicksMark(options)); + } + if (showNeedle) { marks.push(getNeedleMark(options), getNeedleTipMark(options), getPivotMark(options)); } @@ -71,6 +74,22 @@ export const getGaugeMarks = (options: GaugeSpecOptions): Mark[] => { 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: { @@ -170,22 +189,22 @@ const buildCapPath = (name: string, capAngle: string, sweep: 0 | 1): string => { ].join(' '); }; -const getThresholdStartCapMark = ({ name, thresholds, colorScheme }: GaugeSpecOptions): PathMark => ({ +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) }, + fill: { value: getS2ColorValue(thresholds[0].color, colorScheme) }, }, }, }); -const getThresholdEndCapMark = ({ name, thresholds, colorScheme }: GaugeSpecOptions): PathMark => ({ +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) }, + fill: { value: getS2ColorValue(thresholds[thresholds.length - 1].color, colorScheme) }, }, }, }); @@ -202,27 +221,23 @@ const getValueFillStartCapMark = ({ name, color, colorScheme }: GaugeSpecOptions }, }); -const getNeedleMark = ({ name, colorScheme }: GaugeSpecOptions): PathMark => { +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`; - // Tip shoulders end 12px before the inner track edge - const tipR = `(${innerR} - 12)`; - - // Base: ±10.5px perpendicular (21px total width at pivot end) - const bLx = `(${cx} - 10.5*cos(${angle}))`; - const bLy = `(${cy} - 10.5*sin(${angle}))`; - const bRx = `(${cx} + 10.5*cos(${angle}))`; - const bRy = `(${cy} + 10.5*sin(${angle}))`; - - // Tip shoulders: ±4px wide - const tLx = `(${cx} + ${tipR}*sin(${angle}) - 4*cos(${angle}))`; - const tLy = `(${cy} - ${tipR}*cos(${angle}) - 4*sin(${angle}))`; - const tRx = `(${cx} + ${tipR}*sin(${angle}) + 4*cos(${angle}))`; - const tRy = `(${cy} - ${tipR}*cos(${angle}) + 4*sin(${angle}))`; - - // Straight taper — rounding is handled by a separate tip circle mark + 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}`, @@ -244,12 +259,12 @@ const getNeedleMark = ({ name, colorScheme }: GaugeSpecOptions): PathMark => { }; // Separate circle mark at the tip — guarantees a geometrically perfect round cap -const getNeedleTipMark = ({ name, colorScheme }: GaugeSpecOptions): SymbolMark => { +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} - 12)`; + const tipR = `(${innerR} - ${needleTipGap})`; return { type: 'symbol', from: { data: name }, @@ -257,7 +272,7 @@ const getNeedleTipMark = ({ name, colorScheme }: GaugeSpecOptions): SymbolMark = enter: { x: { signal: `${cx} + ${tipR} * sin(${angle})` }, y: { signal: `${cy} - ${tipR} * cos(${angle})` }, - size: { value: 64 }, + size: { value: needleTipDiameter * needleTipDiameter }, shape: { value: 'circle' }, fill: { value: getS2ColorValue('gray-800', colorScheme) }, }, @@ -265,7 +280,7 @@ const getNeedleTipMark = ({ name, colorScheme }: GaugeSpecOptions): SymbolMark = }; }; -const getPivotMark = ({ name, colorScheme }: GaugeSpecOptions): SymbolMark => ({ +const getPivotMark = ({ name, colorScheme, pivotDiameter, pivotStrokeWidth }: GaugeSpecOptions): SymbolMark => ({ type: 'symbol', from: { data: name }, zindex: 1, @@ -273,16 +288,16 @@ const getPivotMark = ({ name, colorScheme }: GaugeSpecOptions): SymbolMark => ({ enter: { x: { signal: `${name}_cx` }, y: { signal: `${name}_cy` }, - size: { value: 324 }, + size: { value: pivotDiameter * pivotDiameter }, fill: { value: '#ffffff' }, stroke: { value: getS2ColorValue('gray-800', colorScheme) }, - strokeWidth: { value: 3 }, + strokeWidth: { value: pivotStrokeWidth }, shape: { value: 'circle' }, }, }, }); -const getTargetTickMark = ({ name, target, colorScheme }: GaugeSpecOptions): RuleMark => ({ +const getTargetTickMark = ({ name }: GaugeSpecOptions): RuleMark => ({ type: 'rule', from: { data: name }, encode: { @@ -298,42 +313,48 @@ const getTargetTickMark = ({ name, target, colorScheme }: GaugeSpecOptions): Rul }, }); -const getValueLabelMark = ({ name, metric, showNeedle }: GaugeSpecOptions): TextMark => ({ - type: 'text', - from: { data: name }, - encode: { - enter: { - x: { signal: `${name}_cx` }, - y: { signal: `${name}_cy + ${name}_radius * 0.18${showNeedle ? ' + 36' : ' - 40'}` }, - text: { field: metric }, - align: { value: 'center' }, - baseline: { value: 'middle' }, - fontSize: { value: showNeedle ? 40 : 56 }, - fontWeight: { value: 800 }, - fontFamily: { value: 'adobe-clean' }, - fill: { value: '#292929' }, +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 }: GaugeSpecOptions): TextMark => ({ - type: 'text', - encode: { - enter: { - x: { signal: `${name}_cx` }, - y: { signal: `${name}_cy + ${name}_radius * 0.4 + 8${showNeedle ? ' + 36' : ' - 40'}` }, - text: { value: wrapLabel(label, showNeedle ? 26 : 14) }, - align: { value: 'center' }, - baseline: { value: 'middle' }, - fontSize: { value: showNeedle ? 26 : 32 }, - fontWeight: { value: 700 }, - fontFamily: { value: 'adobe-clean' }, - fill: { value: '#505050' }, - lineBreak: { value: '\n' }, - lineHeight: { value: showNeedle ? 24 : 32 }, +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[] => [ { @@ -349,8 +370,10 @@ const getRangeLabelMarks = ({ name, minScaleValue, maxScaleValue, colorScheme }: text: { value: String(minScaleValue) }, align: { value: 'center' }, baseline: { value: 'middle' }, - fontSize: { value: 11 }, - fill: { value: getS2ColorValue('gray-600', colorScheme) }, + fontSize: { value: 18 }, + fontWeight: { value: 400 }, + fontFamily: { value: 'adobe-clean' }, + fill: { value: getS2ColorValue('gray-700', colorScheme) }, }, }, }, @@ -367,8 +390,10 @@ const getRangeLabelMarks = ({ name, minScaleValue, maxScaleValue, colorScheme }: text: { value: String(maxScaleValue) }, align: { value: 'center' }, baseline: { value: 'middle' }, - fontSize: { value: 11 }, - fill: { value: getS2ColorValue('gray-600', colorScheme) }, + 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 index 1b8df2059..552d21465 100644 --- a/packages/vega-spec-builder-s2/src/gauge/gaugeSpecBuilder.ts +++ b/packages/vega-spec-builder-s2/src/gauge/gaugeSpecBuilder.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ import { produce } from 'immer'; -import { Data, FormulaTransform, Mark, Signal } from 'vega'; +import { AggregateTransform, Data, FilterTransform, FormulaTransform, Mark, Signal, WindowTransform } from 'vega'; import { DEFAULT_COLOR_SCHEME, @@ -28,7 +28,40 @@ 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'; +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, @@ -66,12 +99,13 @@ export const addGauge = produce< ...options } ) => { + const sc = resolveSizeConfig(size); const gaugeOptions: GaugeSpecOptions = { arcSize, chartTooltips, color, colorScheme, - holeRatio, + holeRatio: Math.min(0.9, Math.max(0.65, holeRatio)), index, label, maxScaleValue, @@ -87,6 +121,22 @@ export const addGauge = produce< 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, }; @@ -97,17 +147,9 @@ export const addGauge = produce< ); export const addData = produce((data, options) => { - const { name, metric, minScaleValue, maxScaleValue, target } = options; + const { name, metric, minScaleValue, maxScaleValue, method, target, ticks } = options; const transforms: Data['transform'] = [ - { - type: 'window', - ops: ['row_number'], - as: [`${name}_rowIndex`], - }, - { - type: 'filter', - expr: `datum.${name}_rowIndex === 1`, - }, + ...getAggregationTransforms(name, metric, method, target), getValueAngleFormula(name, metric, minScaleValue, maxScaleValue), ]; @@ -120,8 +162,117 @@ export const addData = produce((data, options) => { 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, 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 index 8dc0ef79d..1294824df 100644 --- a/packages/vega-spec-builder-s2/src/types/marks/gaugeSpec.types.ts +++ b/packages/vega-spec-builder-s2/src/types/marks/gaugeSpec.types.ts @@ -45,7 +45,7 @@ export interface GaugeOptions { 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.4–0.9. */ + /** 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[]; @@ -57,7 +57,7 @@ export interface GaugeOptions { ticks?: 'minimal' | 'normal' | 'dense'; /** Show minScaleValue and maxScaleValue labels at the arc endpoints. */ showRangeLabels?: boolean; - /** Overall size of the gauge. Named: XL=350px, L=225px, M=150px, S=110px. */ + /** 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 @@ -83,4 +83,21 @@ export interface GaugeSpecOptions extends PartiallyRequired