Skip to content

Commit b8bd446

Browse files
authored
Merge pull request #165 from adobe/supportDurationLabelFormat
Support duration label format
2 parents bf5c6e5 + 0853405 commit b8bd446

File tree

14 files changed

+135
-29
lines changed

14 files changed

+135
-29
lines changed

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ module.exports = {
3131
'\\.(css)$': 'identity-obj-proxy',
3232
'single-spa-react/parcel': 'single-spa-react/lib/cjs/parcel.cjs',
3333
'^.+\\.(css|less|scss)$': 'babel-jest',
34+
'^d3-format$': '<rootDir>/node_modules/d3-format/dist/d3-format.js',
3435
...pathsToModuleNameMapper(compilerOptions.paths),
3536
},
3637
setupFilesAfterEnv: ['@testing-library/jest-dom', 'jest-canvas-mock'],

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"@testing-library/react": "12.1.5",
6969
"@testing-library/user-event": "^14.4.3",
7070
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
71+
"@types/d3-format": "^3.0.4",
7172
"@types/node": "^20.8.2",
7273
"@types/react": "17.0.50",
7374
"@types/react-dom": "17.0.17",
@@ -116,6 +117,7 @@
116117
},
117118
"dependencies": {
118119
"@adobe/react-spectrum": ">= 3.23.0",
120+
"d3-format": "^3.1.0",
119121
"immer": ">= 9.0.0",
120122
"uuid": ">= 9.0.0",
121123
"vega-embed": ">= 6.24.0",

src/VegaChart.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { FC, useEffect, useMemo, useRef } from 'react';
1414
import { TABLE } from '@constants';
1515
import { useDebugSpec } from '@hooks/useDebugSpec';
1616
import { extractValues, isVegaData } from '@specBuilder/specUtils';
17-
import { expressionFunctions } from 'expressionFunctions';
17+
import { expressionFunctions, formatTimeDurationLabels } from 'expressionFunctions';
1818
import { ChartData, ChartProps } from 'types';
1919
import { getLocale } from 'utils/locale';
2020
import { Config, Padding, Renderers, Spec, View } from 'vega';
@@ -89,7 +89,10 @@ export const VegaChart: FC<VegaChartProps> = ({
8989
embed(containerRef.current, specCopy, {
9090
actions: false,
9191
config,
92-
expressionFunctions: expressionFunctions,
92+
expressionFunctions: {
93+
...expressionFunctions,
94+
formatTimeDurationLabels: formatTimeDurationLabels(numberLocale),
95+
},
9396
formatLocale: numberLocale as unknown as Record<string, unknown>, // these are poorly typed by vega-embed
9497
height,
9598
padding,

src/expressionFunctions.test.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import { expressionFunctions } from './expressionFunctions';
13+
import { numberLocales } from '@locales';
14+
import { expressionFunctions, formatTimeDurationLabels } from './expressionFunctions';
1415

1516
describe('truncateText()', () => {
1617
const longText =
@@ -19,10 +20,33 @@ describe('truncateText()', () => {
1920
test('should truncate text that is too long', () => {
2021
expect(expressionFunctions.truncateText(longText, 24)).toBe('Lorem ipsum dolor s…');
2122
expect(expressionFunctions.truncateText(longText, 100)).toBe(
22-
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsu…'
23+
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsu…',
2324
);
2425
});
2526
test('should not truncate text that is shorter than maxLength', () => {
2627
expect(expressionFunctions.truncateText(shortText, 100)).toBe(shortText);
2728
});
2829
});
30+
31+
describe('formatTimeDurationLabels()', () => {
32+
test('should format durations correctly', () => {
33+
const formatDurationsEnUS = formatTimeDurationLabels(numberLocales['en-US']);
34+
const formatDurationsFrFr = formatTimeDurationLabels(numberLocales['fr-FR']);
35+
const formatDurationsDeDe = formatTimeDurationLabels(numberLocales['de-DE']);
36+
37+
expect(formatDurationsEnUS({ index: 0, label: '0', value: 1 })).toBe('00:00:01');
38+
expect(formatDurationsEnUS({ index: 0, label: '0', value: 61 })).toBe('00:01:01');
39+
expect(formatDurationsEnUS({ index: 0, label: '0', value: 3661 })).toBe('01:01:01');
40+
expect(formatDurationsEnUS({ index: 0, label: '0', value: 3603661 })).toBe('1,001:01:01');
41+
expect(formatDurationsFrFr({ index: 0, label: '0', value: 3603661 })).toBe('1\u00a0001:01:01');
42+
expect(formatDurationsDeDe({ index: 0, label: '0', value: 3603661 })).toBe('1.001:01:01');
43+
});
44+
test('should default to using en-US', () => {
45+
const formatDurations = formatTimeDurationLabels();
46+
expect(formatDurations({ index: 0, label: '0', value: 3603661 })).toBe('1,001:01:01');
47+
});
48+
test('should original string if type of value is string', () => {
49+
const formatDurationsEnUS = formatTimeDurationLabels(numberLocales['en-US']);
50+
expect(formatDurationsEnUS({ index: 0, label: '0', value: 'hello world!' })).toBe('hello world!');
51+
});
52+
});

src/expressionFunctions.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
* OF ANY KIND, either express or implied. See the License for the specific language
1010
* governing permissions and limitations under the License.
1111
*/
12+
import { numberLocales } from '@locales';
1213
import { ADOBE_CLEAN_FONT } from '@themes/spectrumTheme';
14+
import { FormatLocaleDefinition, formatLocale } from 'd3-format';
1315
import { FontWeight } from 'vega';
1416

1517
interface LabelDatum {
@@ -31,6 +33,25 @@ const formatPrimaryTimeLabels = () => {
3133
};
3234
};
3335

36+
/**
37+
* Formats a duration in seconds as HH:MM:SS.
38+
* Function is initialized with a number locale. This ensures that the thousands separator is correct for the locale
39+
* @param numberLocale
40+
* @returns formatted sting (HH:MM:SS)
41+
*/
42+
export const formatTimeDurationLabels = (numberLocale: FormatLocaleDefinition = numberLocales['en-US']) => {
43+
const d3 = formatLocale(numberLocale);
44+
// 0 padded, minimum 2 digits, thousands separator, integer format
45+
const formatDuration = d3.format('02,d');
46+
return ({ value }: LabelDatum) => {
47+
if (typeof value === 'string') return value;
48+
const seconds = formatDuration(Math.floor(value % 60));
49+
const minutes = formatDuration(Math.floor((value / 60) % 60));
50+
const hours = formatDuration(Math.floor(value / 60 / 60));
51+
return `${hours}:${minutes}:${seconds}`;
52+
};
53+
};
54+
3455
/**
3556
* Utility function that will log the value to the console and return it
3657
* @param value

src/locales/numberLocales/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ import svse from './sv-SE.json';
6969
import ukua from './uk-UA.json';
7070
import zhcn from './zh-CN.json';
7171

72-
export const numberLocale: Record<NumberLocaleCode, NumberLocale> = {
72+
export const numberLocales: Record<NumberLocaleCode, NumberLocale> = {
7373
['ar-AE']: arae as NumberLocale,
7474
['ar-BH']: arbh as NumberLocale,
7575
['ar-DJ']: ardj as NumberLocale,

src/locales/timeLocales/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import ukua from './uk-UA.json';
4848
import zhcn from './zh-CN.json';
4949
import zhtw from './zh-TW.json';
5050

51-
export const timeLocale: Record<TimeLocaleCode, TimeLocale> = {
51+
export const timeLocales: Record<TimeLocaleCode, TimeLocale> = {
5252
['ar-EG']: areg as TimeLocale,
5353
['ar-SY']: arsy as TimeLocale,
5454
['ca-ES']: caes as TimeLocale,

src/specBuilder/axis/axisLabelUtils.test.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -160,17 +160,17 @@ describe('getLabelFormat()', () => {
160160
test('should include the number format test if numberFormat exists', () => {
161161
const labelFormat = getLabelFormat(
162162
{ ...defaultAxisProps, labelFormat: 'linear', numberFormat: '.2f' },
163-
'xLinear'
163+
'xLinear',
164164
);
165165
expect(labelFormat).toHaveLength(4);
166166
expect(labelFormat[0]).toEqual({ test: 'isNumber(datum.value)', signal: "format(datum.value, '.2f')" });
167167
});
168168
test('should not include the number format test if numberFormat does not exist or is an empty string', () => {
169169
expect(
170-
getLabelFormat({ ...defaultAxisProps, labelFormat: 'linear', numberFormat: undefined }, 'xLinear')
170+
getLabelFormat({ ...defaultAxisProps, labelFormat: 'linear', numberFormat: undefined }, 'xLinear'),
171171
).toHaveLength(3);
172172
expect(
173-
getLabelFormat({ ...defaultAxisProps, labelFormat: 'linear', numberFormat: '' }, 'xLinear')
173+
getLabelFormat({ ...defaultAxisProps, labelFormat: 'linear', numberFormat: '' }, 'xLinear'),
174174
).toHaveLength(3);
175175
});
176176
test('should include text truncation if truncateText is true', () => {
@@ -180,21 +180,27 @@ describe('getLabelFormat()', () => {
180180
});
181181
test('should not include text truncation if the scale name does not include band', () => {
182182
expect(getLabelFormat({ ...defaultAxisProps, truncateLabels: true }, 'xLinear')[2].signal).not.toContain(
183-
'truncateText'
183+
'truncateText',
184184
);
185185
});
186186
test('should not include truncae text if labels are perpendicular to the axis', () => {
187187
expect(
188188
getLabelFormat(
189189
{ ...defaultAxisProps, truncateLabels: true, position: 'bottom', labelOrientation: 'vertical' },
190-
'xBand'
191-
)[2].signal
190+
'xBand',
191+
)[2].signal,
192192
).not.toContain('truncateText');
193193
expect(
194194
getLabelFormat(
195195
{ ...defaultAxisProps, truncateLabels: true, position: 'left', labelOrientation: 'horizontal' },
196-
'yBand'
197-
)[2].signal
196+
'yBand',
197+
)[2].signal,
198198
).not.toContain('truncateText');
199199
});
200+
test('should return duration formatter if labelFormat is duration', () => {
201+
expect(getLabelFormat({ ...defaultAxisProps, labelFormat: 'duration' }, 'xLinear')).toHaveProperty(
202+
'signal',
203+
'formatTimeDurationLabels(datum)',
204+
);
205+
});
200206
});

src/specBuilder/axis/axisLabelUtils.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export const getTimeLabelFormats = (granularity: Granularity): [string, string,
7272
export const getControlledLabelAnchorValues = (
7373
position: Position,
7474
labelOrientaion: Orientation,
75-
labelAlign?: LabelAlign
75+
labelAlign?: LabelAlign,
7676
): { align: Align | undefined; baseline: Baseline | undefined } => {
7777
// if there isn't a labelAlign, we don't want to set the align or baseline
7878
if (!labelAlign) return { align: undefined, baseline: undefined };
@@ -94,7 +94,7 @@ export const getLabelAnchorValues = (
9494
labelOrientaion: Orientation,
9595
labelAlign: LabelAlign,
9696
vegaLabelAlign?: Align,
97-
vegaLabelBaseline?: Baseline
97+
vegaLabelBaseline?: Baseline,
9898
): { labelAlign: Align; labelBaseline: Baseline } => {
9999
const { align, baseline } = getLabelAnchor(position, labelOrientaion, labelAlign);
100100
// if vegaLabelAlign or vegaLabelBaseline are set, we want to use those values instead of the calculated values
@@ -114,7 +114,7 @@ export const getLabelAnchorValues = (
114114
export const getLabelAnchor = (
115115
position: Position,
116116
labelOrientaion: Orientation,
117-
labelAlign: LabelAlign
117+
labelAlign: LabelAlign,
118118
): { align: Align; baseline: Baseline } => {
119119
let align: Align;
120120
let baseline: Baseline;
@@ -186,7 +186,7 @@ export const getLabelAngle = (labelOrientaion: Orientation): number => {
186186
export const getLabelBaseline = (
187187
labelAlign: LabelAlign | undefined,
188188
position: Position,
189-
vegaLabelBaseline?: Baseline
189+
vegaLabelBaseline?: Baseline,
190190
): Baseline | undefined => {
191191
if (vegaLabelBaseline) return vegaLabelBaseline;
192192
if (!labelAlign) return;
@@ -212,7 +212,7 @@ export const getLabelBaseline = (
212212
export const getLabelOffset = (
213213
labelAlign: LabelAlign,
214214
scaleName: string,
215-
vegaLabelOffset?: NumberValue
215+
vegaLabelOffset?: NumberValue,
216216
): NumberValue | undefined => {
217217
if (vegaLabelOffset !== undefined) return vegaLabelOffset;
218218
switch (labelAlign) {
@@ -232,11 +232,14 @@ export const getLabelOffset = (
232232
*/
233233
export const getLabelFormat = (
234234
{ labelFormat, labelOrientation, numberFormat, position, truncateLabels }: AxisSpecProps,
235-
scaleName: string
235+
scaleName: string,
236236
): ProductionRule<TextValueRef> => {
237237
if (labelFormat === 'percentage') {
238238
return [{ test: 'isNumber(datum.value)', signal: "format(datum.value, '~%')" }, { signal: 'datum.value' }];
239239
}
240+
if (labelFormat === 'duration') {
241+
return { signal: 'formatTimeDurationLabels(datum)' };
242+
}
240243

241244
// if it's a number and greater than or equal to 1000, we want to format it in scientific notation (but with B instead of G) ex. 1K, 20M, 1.3B
242245
return [
@@ -270,7 +273,7 @@ export const getAxisLabelsEncoding = (
270273
labelKey: 'label' | 'subLabel',
271274
labelOrientation: Orientation,
272275
position: Position,
273-
signalName: string
276+
signalName: string,
274277
): GuideEncodeEntry<TextEncodeEntry> => ({
275278
update: {
276279
text: [
@@ -306,7 +309,7 @@ export const getEncodedLabelAnchor = (
306309
position: Position,
307310
signalName: string,
308311
labelOrientation: Orientation,
309-
defaultLabelAlign: LabelAlign
312+
defaultLabelAlign: LabelAlign,
310313
): EncodeEntry => {
311314
const baseTestString = `indexof(pluck(${signalName}, 'value'), datum.value) !== -1 && ${signalName}[indexof(pluck(${signalName}, 'value'), datum.value)]`;
312315
const baseSignalString = `${signalName}[indexof(pluck(${signalName}, 'value'), datum.value)]`;

src/stories/components/Axis/Axis.story.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ const LinearAxisStory: StoryFn<typeof Axis> = (args): ReactElement => {
8181
);
8282
};
8383

84+
const DurationStory: StoryFn<typeof Axis> = (args): ReactElement => {
85+
const chartProps = useChartProps({ data: workspaceTrendsData, width: 600 });
86+
return (
87+
<Chart {...chartProps}>
88+
<Axis {...args} />
89+
<Axis position="bottom" labelFormat="time" />
90+
<Line color="series" dimension="datetime" scaleType="time" />
91+
</Chart>
92+
);
93+
};
94+
8495
const NonLinearAxisStory: StoryFn<typeof Axis> = (args): ReactElement => {
8596
const chartProps = useChartProps({ data: workspaceTrendsData, width: 600 });
8697
return (
@@ -122,6 +133,14 @@ Basic.args = {
122133
title: 'Conversion Rate',
123134
};
124135

136+
const DurationLabelFormat = bindWithProps(DurationStory);
137+
DurationLabelFormat.args = {
138+
position: 'left',
139+
grid: true,
140+
labelFormat: 'duration',
141+
title: 'Time spent',
142+
};
143+
125144
const Time = bindWithProps(TimeAxisStory);
126145
Time.args = {
127146
granularity: 'day',
@@ -204,6 +223,7 @@ export {
204223
Basic,
205224
ControlledLabels,
206225
CustomXRange,
226+
DurationLabelFormat,
207227
NonLinearAxis,
208228
NumberFormat,
209229
SubLabels,

0 commit comments

Comments
 (0)