Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion src/expressionFunctions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@
*/
import { numberLocales } from '@locales';

import { expressionFunctions, formatTimeDurationLabels } from './expressionFunctions';
import {
LabelDatum,
expressionFunctions,
formatHorizontalTimeAxisLabels,
formatTimeDurationLabels,
formatVerticalAxisTimeLabels,
} from './expressionFunctions';

describe('truncateText()', () => {
const longText =
Expand Down Expand Up @@ -50,3 +56,41 @@ describe('formatTimeDurationLabels()', () => {
expect(formatDurationsEnUS({ index: 0, label: '0', value: 'hello world!' })).toBe('hello world!');
});
});

describe('formatHorizontalTimeAxisLabels()', () => {
let formatter: (datum: LabelDatum) => string;
beforeEach(() => {
formatter = formatHorizontalTimeAxisLabels();
});

test('should return label if index is 0', () => {
expect(formatter({ index: 0, label: '2024', value: 1 })).toBe('2024');
expect(formatter({ index: 0, label: 'Nov', value: 1 })).toBe('Nov');
expect(formatter({ index: 0, label: 'Nov', value: 2 })).toBe('Nov');
expect(formatter({ index: 0, label: 'Nov 15', value: 1 })).toBe('Nov 15');
});

test('should return "" when previous label was the same', () => {
expect(formatter({ index: 0, label: '2024', value: 2 })).toBe('2024');
expect(formatter({ index: 1, label: '2024', value: 2 })).toBe('');
});
});

describe('formatVerticalAxisTimeLabels()', () => {
let formatter: (datum: LabelDatum) => string;
beforeEach(() => {
formatter = formatVerticalAxisTimeLabels();
});

test('should return full label if index is 0', () => {
expect(formatter({ index: 0, label: '2024 \u2000Jan', value: 1 })).toBe('2024 \u2000Jan');
expect(formatter({ index: 0, label: 'Nov \u200015', value: 1 })).toBe('Nov \u200015');
expect(formatter({ index: 0, label: 'Nov \u200015', value: 2 })).toBe('Nov \u200015');
expect(formatter({ index: 0, label: 'Nov 15 \u200012 AM', value: 1 })).toBe('Nov 15 \u200012 AM');
});

test('should drop the larger time granularity when previous label was the same larger time granularity', () => {
expect(formatter({ index: 0, label: '2024 \u2000Jan', value: 1 })).toBe('2024 \u2000Jan');
expect(formatter({ index: 1, label: '2024 \u2000Feb', value: 1 })).toBe('Feb');
});
});
25 changes: 21 additions & 4 deletions src/expressionFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,17 @@ import { ADOBE_CLEAN_FONT } from '@themes/spectrumTheme';
import { FormatLocaleDefinition, formatLocale } from 'd3-format';
import { FontWeight } from 'vega';

interface LabelDatum {
export interface LabelDatum {
index: number;
label: string;
value: string | number;
}

/**
* Hides labels that are the same as the previous label
* @returns
* @returns string
*/
const formatPrimaryTimeLabels = () => {
export const formatHorizontalTimeAxisLabels = () => {
let prevLabel: string;
return (datum: LabelDatum) => {
const showLabel = datum.index === 0 || prevLabel !== datum.label;
Expand All @@ -33,6 +33,22 @@ const formatPrimaryTimeLabels = () => {
};
};

/**
* Hides the larger granularity instead of repeating it for each tick
* @returns string
*/
export const formatVerticalAxisTimeLabels = () => {
let prevLabel: string;
return (datum: LabelDatum) => {
const labels = datum.label.split('\u2000');
const label = labels[0];

const showLabel = datum.index === 0 || prevLabel !== label;
prevLabel = label;
return showLabel ? datum.label : labels[1];
};
};

/**
* Formats a duration in seconds as HH:MM:SS.
* Function is initialized with a number locale. This ensures that the thousands separator is correct for the locale
Expand Down Expand Up @@ -109,7 +125,8 @@ const truncateText = (text: string, maxWidth: number, fontWeight: FontWeight = '

export const expressionFunctions = {
consoleLog,
formatPrimaryTimeLabels: formatPrimaryTimeLabels(),
formatHorizontalTimeAxisLabels: formatHorizontalTimeAxisLabels(),
formatVerticalAxisTimeLabels: formatVerticalAxisTimeLabels(),
getLabelWidth,
truncateText,
};
18 changes: 10 additions & 8 deletions src/specBuilder/axis/axisLabelUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,24 @@ export const getLabelValue = (label: Label | number | string): string | number =
* @param granularity
* @returns [secondaryFormat, primaryFormat, tickCount]
*/
export const getTimeLabelFormats = (granularity: Granularity): [string, string, TickCount] => {
export const getTimeLabelFormats = (
granularity: Granularity
): { secondaryLabelFormat: string; primaryLabelFormat: string; tickCount: TickCount } => {
switch (granularity) {
case 'minute':
return ['%-I:%M %p', '%b %-d', 'minute'];
return { secondaryLabelFormat: '%-I:%M %p', primaryLabelFormat: '%b %-d', tickCount: 'minute' };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love this improvement! Much easier to code in and understand 🎉

case 'hour':
return ['%-I %p', '%b %-d', 'hour'];
return { secondaryLabelFormat: '%-I %p', primaryLabelFormat: '%b %-d', tickCount: 'hour' };
case 'day':
return ['%-d', '%b', 'day'];
return { secondaryLabelFormat: '%-d', primaryLabelFormat: '%b', tickCount: 'day' };
case 'week':
return ['%-d', '%b', 'week'];
return { secondaryLabelFormat: '%-d', primaryLabelFormat: '%b', tickCount: 'week' };
case 'month':
return ['%b', '%Y', 'month'];
return { secondaryLabelFormat: '%b', primaryLabelFormat: '%Y', tickCount: 'month' };
case 'quarter':
return ['Q%q', '%Y', { interval: 'month', step: 3 }];
return { secondaryLabelFormat: 'Q%q', primaryLabelFormat: '%Y', tickCount: { interval: 'month', step: 3 } };
default:
return ['%-d', '%b', 'day'];
return { secondaryLabelFormat: '%-d', primaryLabelFormat: '%b', tickCount: 'day' };
}
};

Expand Down
100 changes: 80 additions & 20 deletions src/specBuilder/axis/axisUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/
import { Axis, Mark, Scale, SignalRef } from 'vega';

import { AxisSpecProps, Position } from '../../types';
import { AxisSpecProps, Granularity, Position } from '../../types';
import {
getAxisLabelsEncoding,
getLabelAnchorValues,
Expand Down Expand Up @@ -74,41 +74,101 @@ export const getDefaultAxis = (axisProps: AxisSpecProps, scaleName: string): Axi
* @param axisProps
* @returns axes
*/
export const getTimeAxes = (
export const getTimeAxes = (scaleName: string, axisProps: AxisSpecProps): Axis[] => {
return [getSecondaryTimeAxis(scaleName, axisProps), ...getPrimaryTimeAxis(scaleName, axisProps)];
};

/**
* Generates the secondary time axis from the axis props
* This is the axis that shows the smaller granularity
* If this is a vertical axis, it will also show the larger granularity and will hide repeats of the larger granularity
* @param scaleName
* @param axisProps
* @returns axis
*/
const getSecondaryTimeAxis = (
scaleName: string,
{
granularity,
grid,
labelAlign,
labelFontWeight,
labelOrientation,
position,
ticks,
title,
vegaLabelAlign,
vegaLabelBaseline,
}: AxisSpecProps
): Axis => {
const { tickCount } = getTimeLabelFormats(granularity);

return {
scale: scaleName,
orient: position,
grid,
ticks,
tickCount: scaleName.includes('Time') ? tickCount : undefined,
title,
formatType: 'time',
labelAngle: getLabelAngle(labelOrientation),
labelSeparation: 12,
...getSecondaryTimeAxisLabelFormatting(granularity, position),
...getLabelAnchorValues(position, labelOrientation, labelAlign, vegaLabelAlign, vegaLabelBaseline),
};
};

const getSecondaryTimeAxisLabelFormatting = (granularity: Granularity, position: Position): Partial<Axis> => {
const { secondaryLabelFormat, primaryLabelFormat } = getTimeLabelFormats(granularity);
const isVerticalAxis = ['left', 'right'].includes(position);
if (isVerticalAxis) {
return {
format: `${primaryLabelFormat}\u2000${secondaryLabelFormat}`,
encode: {
labels: {
update: {
text: { signal: 'formatVerticalAxisTimeLabels(datum)' },
},
},
},
};
}

return {
format: secondaryLabelFormat,
};
};

/**
* Generates the primary time axis from the axis props
* This is the axis that shows the larger granularity and hides duplicate labels
* Only returns an axis for horizontal axes
* @param scaleName
* @param axisProps
* @returns axis
*/
const getPrimaryTimeAxis = (
scaleName: string,
{
granularity,
labelAlign,
labelOrientation,
labelFontWeight,
position,
ticks,
vegaLabelAlign,
vegaLabelBaseline,
}: AxisSpecProps
): Axis[] => {
const [secondaryFormat, primaryFormat, tickGranularity] = getTimeLabelFormats(granularity);
if (['left', 'right'].includes(position)) {
return [];
}
const { primaryLabelFormat, tickCount } = getTimeLabelFormats(granularity);
return [
{
scale: scaleName,
orient: position,
grid,
ticks,
tickCount: scaleName.includes('Time') ? tickGranularity : undefined,
title,
format: secondaryFormat,
formatType: 'time',
labelAngle: getLabelAngle(labelOrientation),
labelSeparation: 12,
...getLabelAnchorValues(position, labelOrientation, labelAlign, vegaLabelAlign, vegaLabelBaseline),
},
{
scale: scaleName,
orient: position,
format: primaryFormat,
tickCount: scaleName.includes('Time') ? tickGranularity : undefined,
format: primaryLabelFormat,
tickCount: scaleName.includes('Time') ? tickCount : undefined,
formatType: 'time',
labelOverlap: 'greedy',
labelFontWeight,
Expand All @@ -120,7 +180,7 @@ export const getTimeAxes = (
dy: { value: (ticks ? 28 : 20) * (position === 'top' ? -1 : 1) }, // account for tick height
},
update: {
text: { signal: 'formatPrimaryTimeLabels(datum)' },
text: { signal: 'formatHorizontalTimeAxisLabels(datum)' },
},
},
},
Expand Down
25 changes: 25 additions & 0 deletions src/stories/components/Axis/Axis.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,20 @@ const TimeAxisStory: StoryFn<typeof Axis> = (args): ReactElement => {
);
};

const VerticalTimeAxisStory: StoryFn<typeof Axis> = (args): ReactElement => {
const chartProps = useChartProps({
data: timeData[args.granularity ?? DEFAULT_GRANULARITY],
width: 600,
height: 800,
});
return (
<Chart {...chartProps}>
<Axis {...args} />
<Bar orientation="horizontal" dimension="datetime" />
</Chart>
);
};

const SubLabelStory: StoryFn<typeof Axis> = (args): ReactElement => {
const chartProps = useChartProps({ data: barData, width: 600 });
return (
Expand Down Expand Up @@ -244,6 +258,16 @@ ControlledLabels.args = {
],
};

const VerticalTimeAxis = bindWithProps(VerticalTimeAxisStory);
VerticalTimeAxis.args = {
granularity: 'day',
position: 'left',
baseline: true,
labelFormat: 'time',
ticks: true,
labelAlign: 'center',
};

export {
Basic,
ControlledLabels,
Expand All @@ -256,4 +280,5 @@ export {
TickMinStep,
Time,
TruncateLabels,
VerticalTimeAxis,
};
Loading
Loading