Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(goal): auto generated linear ticks #1637

Merged
merged 16 commits into from
Apr 6, 2022
Merged
Show file tree
Hide file tree
Changes from 12 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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions integration/tests/goal_stories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ describe('Goal stories', () => {
);
});

describe('auto ticks', () => {
it.each<boolean>([true, false])('reverse %p', async (reverse) => {
await common.expectChartAtUrlToMatchScreenshot(
`http://localhost:9001/?path=/story/goal-alpha--auto-linear-ticks&knob-reverse=${reverse}`,
);
});
});

describe('sagitta shifted goal charts', () => {
it.each<[title: string, startAngle: number, endAngle: number]>([
// top openings
Expand Down
15 changes: 11 additions & 4 deletions packages/charts/api/charts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,14 @@ export function getNodeName(node: ArrayNode): string;
// @alpha
export const Goal: (props: SFProps<GoalSpec, keyof typeof buildProps['overrides'], keyof typeof buildProps['defaults'], keyof typeof buildProps['optionals'], keyof typeof buildProps['requires']>) => null;

// @alpha (undocumented)
export interface GoalDomainRange {
// (undocumented)
max: number;
// (undocumented)
min: number;
}

// @alpha (undocumented)
export type GoalLabelAccessor = LabelAccessor<BandFillColorAccessorInput>;

Expand All @@ -978,8 +986,7 @@ export interface GoalSpec extends Spec {
bandFillColor: BandFillColorAccessor;
// (undocumented)
bandLabels: string[];
// (undocumented)
bands: number[];
bands?: number | number[];
// (undocumented)
base: number;
// (undocumented)
Expand All @@ -988,6 +995,7 @@ export interface GoalSpec extends Spec {
centralMinor: string | GoalLabelAccessor;
// (undocumented)
chartType: typeof ChartType.Goal;
domain: GoalDomainRange;
// (undocumented)
labelMajor: string | GoalLabelAccessor;
// (undocumented)
Expand All @@ -1000,8 +1008,7 @@ export interface GoalSpec extends Spec {
subtype: GoalSubtype;
// (undocumented)
target?: number;
// (undocumented)
ticks: number[];
ticks?: number | number[];
// (undocumented)
tickValueFormatter: GoalLabelAccessor;
// (undocumented)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,11 @@ export type ShapeViewModel = {
const commonDefaults = {
base: 0,
actual: 50,
ticks: [0, 25, 50, 75, 100],
};

/** @internal */
export const defaultGoalSpec = {
...commonDefaults,
bands: [50, 75, 100],
bandFillColor: ({ value, highestValue, lowestValue }: BandFillColorAccessorInput) => {
return getGreensColorScale(0.5, [highestValue, lowestValue])(value);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
* Side Public License, v 1.
*/

import { Radian } from '../../../../common/geometry';
import { ScaleContinuous } from '../../../../scales';
import { Dimensions } from '../../../../utils/dimensions';
import { Theme } from '../../../../utils/themes/theme';
import { GoalSpec } from '../../specs';
import { GoalSubtype } from '../../specs/constants';
import { BulletViewModel, PickFunction, ShapeViewModel } from '../types/viewmodel_types';
import { clamp, clampAll, isBetween, isFiniteNumber, isNil } from './../../../../utils/common';

/** @internal */
export function shapeViewModel(spec: GoalSpec, theme: Theme, chartDimensions: Dimensions): ShapeViewModel {
Expand All @@ -24,11 +28,9 @@ export function shapeViewModel(spec: GoalSpec, theme: Theme, chartDimensions: Di

const {
subtype,
base,
target,
actual,
bands,
ticks,
bands,
domain,
bandFillColor,
tickValueFormatter,
labelMajor,
Expand All @@ -39,13 +41,40 @@ export function shapeViewModel(spec: GoalSpec, theme: Theme, chartDimensions: Di
angleStart,
angleEnd,
} = spec;
const [lowestValue, highestValue] = [base, ...(target ? [target] : []), actual, ...bands, ...ticks].reduce(
([min, max], value) => [Math.min(min, value), Math.max(max, value)],
[Infinity, -Infinity],
);
const lowestValue = isFiniteNumber(domain.min) ? domain.min : 0;
const highestValue = isFiniteNumber(domain.max) ? domain.max : 1;
const base = clamp(spec.base, lowestValue, highestValue);
const target =
!isNil(spec.target) && spec.target <= highestValue && spec.target >= lowestValue ? spec.target : undefined;
const actual = clamp(spec.actual, lowestValue, highestValue);
const finalTicks = Array.isArray(ticks)
? ticks.filter(isBetween(lowestValue, highestValue))
: new ScaleContinuous(
{
type: 'linear',
domain: [lowestValue, highestValue],
range: [0, 1],
},
{
desiredTickCount: ticks ?? getDesiredTicks(subtype, angleStart, angleEnd),
},
).ticks();

const finalBands = Array.isArray(bands)
? bands.reduce(...clampAll(lowestValue, highestValue))
: new ScaleContinuous(
{
type: 'linear',
domain: [lowestValue, highestValue],
range: [0, 1],
},
{
desiredTickCount: bands ?? getDesiredTicks(subtype, angleStart, angleEnd),
},
).ticks();

const aboveBaseCount = bands.filter((b: number) => b > base).length;
const belowBaseCount = bands.filter((b: number) => b <= base).length;
const aboveBaseCount = finalBands.filter((b: number) => b > base).length;
const belowBaseCount = finalBands.filter((b: number) => b <= base).length;

const callbackArgs = {
base,
Expand All @@ -62,12 +91,12 @@ export function shapeViewModel(spec: GoalSpec, theme: Theme, chartDimensions: Di
base,
target,
actual,
bands: bands.map((value: number, index: number) => ({
bands: finalBands.map((value: number, index: number) => ({
value,
fillColor: bandFillColor({ value, index, ...callbackArgs }),
text: bandLabels,
})),
ticks: ticks.map((value: number, index: number) => ({
ticks: finalTicks.map((value: number, index: number) => ({
value,
text: tickValueFormatter({ value, index, ...callbackArgs }),
})),
Expand Down Expand Up @@ -98,3 +127,9 @@ export function shapeViewModel(spec: GoalSpec, theme: Theme, chartDimensions: Di
pickQuads,
};
}

function getDesiredTicks(subtype: GoalSubtype, angleStart: Radian, angleEnd: Radian) {
if (subtype !== GoalSubtype.Goal) return 5;
const arc = Math.abs(angleStart - angleEnd);
return Math.ceil(arc / (Math.PI / 4));
}
20 changes: 18 additions & 2 deletions packages/charts/src/chart_types/goal_chart/specs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export type BandFillColorAccessor = (input: BandFillColorAccessorInput) => Color
/** @alpha */
export type GoalLabelAccessor = LabelAccessor<BandFillColorAccessorInput>;

/** @alpha */
export interface GoalDomainRange {
min: number;
max: number;
}

/** @alpha */
export interface GoalSpec extends Spec {
specType: typeof SpecType.Series;
Expand All @@ -45,8 +51,18 @@ export interface GoalSpec extends Spec {
base: number;
target?: number;
actual: number;
bands: number[];
ticks: number[];
/**
* array of discrete band intervals or approximate number of desired bands
*/
bands?: number | number[];
/**
* Array of discrete tick values or approximate number of desired ticks
*/
ticks?: number | number[];
/**
* Domain extents of goal chart. Overrides computed extents.
*/
domain: GoalDomainRange;
bandFillColor: BandFillColorAccessor;
tickValueFormatter: GoalLabelAccessor;
labelMajor: string | GoalLabelAccessor;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ describe('Accessibility', () => {
target={260}
actual={170}
bands={[200, 250, 300]}
domain={{ min: 0, max: 300 }}
ticks={[0, 50, 100, 150, 200, 250, 300]}
labelMajor="Revenue 2020 YTD "
labelMinor="(thousand USD) "
Expand All @@ -155,6 +156,7 @@ describe('Accessibility', () => {
target={260}
actual={170}
bands={bandsAscending}
domain={{ min: 0, max: 300 }}
ticks={[0, 50, 100, 150, 200, 250, 300]}
labelMajor="Revenue 2020 YTD "
labelMinor="(thousand USD) "
Expand Down
20 changes: 20 additions & 0 deletions packages/charts/src/utils/common.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
isUniqueArray,
isDefined,
isDefinedFrom,
isBetween,
clampAll,
} from './common';

describe('common utilities', () => {
Expand Down Expand Up @@ -1025,4 +1027,22 @@ describe('#isDefinedFrom', () => {
);
expect(result).toEqual(values.slice(0, 3));
});

describe('#isBetween', () => {
it('should filter array values between min and max inclusive', () => {
expect([1, 2, 3, 4, 5, 6].filter(isBetween(2, 5, false))).toEqual([2, 3, 4, 5]);
});
it('should filter array values between min and max exclusive', () => {
expect([1, 2, 3, 4, 5, 6].filter(isBetween(2, 5, true))).toEqual([3, 4]);
});
});

describe('#clampAll', () => {
it('should clamp each value in array between min and max', () => {
expect([0, 200, 400].reduce(...clampAll(100, 300))).toEqual([100, 200, 300]);
});
it('should clamp array values and remove duplicates', () => {
expect([0, 100, 200, 300, 400].reduce(...clampAll(100, 300))).toEqual([100, 200, 300]);
});
});
});
27 changes: 27 additions & 0 deletions packages/charts/src/utils/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,3 +645,30 @@ export function stripUndefined<R extends Record<string, unknown>>(source: R): R
return acc;
}, {} as R);
}

/**
* Returns `Array.filter` callback for values between a min and max
* @internal
*/
export const isBetween = (min: number, max: number, exclusive = false): ((n: number) => boolean) =>
exclusive ? (n) => n < max && n > min : (n) => n <= max && n >= min;

/**
* Returns `Array.reduce` callback to clamp values and remove duplicates
* @internal
*/
export const clampAll = (
min: number,
max: number,
): [callbackfn: (acc: number[], value: number) => number[], initialAcc: number[]] => {
const seen = new Set<number>();
return [
(acc: number[], n: number) => {
const clampValue = clamp(n, min, max);
if (!seen.has(clampValue)) acc.push(clampValue);
seen.add(clampValue);
return acc;
},
[],
];
};
1 change: 1 addition & 0 deletions storybook/stories/goal/10_band_in_band.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const Example = () => (
target={0}
actual={0}
bands={[225, 300]}
domain={{ min: 0, max: 300 }}
ticks={[0, 50, 100, 150, 200, 250, 300]}
tickValueFormatter={({ value }: BandFillColorAccessorInput) => String(value)}
bandFillColor={({ value }: BandFillColorAccessorInput) => bandFillColor(value)}
Expand Down
1 change: 1 addition & 0 deletions storybook/stories/goal/11_gaps.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const Example = () => {
base={0}
target={showTarget ? target : undefined}
actual={280}
domain={{ min: 0, max: 300 }}
bands={[199, 201, 249, 251, 300]}
ticks={[0, 50, 100, 150, 200, 250, 300]}
tickValueFormatter={({ value }: BandFillColorAccessorInput) => String(value)}
Expand Down
1 change: 1 addition & 0 deletions storybook/stories/goal/12_range.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const Example = () => (
target={0}
actual={0}
bands={[215, 235, 300]}
domain={{ min: 0, max: 300 }}
ticks={[0, 50, 100, 150, 200, 250, 300]}
tickValueFormatter={({ value }: BandFillColorAccessorInput) => String(value)}
bandFillColor={({ value }: BandFillColorAccessorInput) => bandFillColor(value)}
Expand Down
1 change: 1 addition & 0 deletions storybook/stories/goal/13_confidence_level.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const Example = () => (
base={0}
target={226.5}
actual={0}
domain={{ min: 0, max: 300 }}
bands={[210, 218, 224, 229, 235, 243, 300]}
ticks={[0, 50, 100, 150, 200, 250, 300]}
tickValueFormatter={({ value }: BandFillColorAccessorInput) => String(value)}
Expand Down
1 change: 1 addition & 0 deletions storybook/stories/goal/14_one_third.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const Example = () => (
base={0}
target={260}
actual={280}
domain={{ min: 0, max: 300 }}
bands={[200, 250, 300]}
ticks={[0, 50, 100, 150, 200, 250, 300]}
tickValueFormatter={({ value }: BandFillColorAccessorInput) => String(value)}
Expand Down
1 change: 1 addition & 0 deletions storybook/stories/goal/15_half_circle.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const Example = () => (
base={0}
target={260}
actual={280}
domain={{ min: 0, max: 300 }}
bands={[200, 250, 300]}
ticks={[0, 50, 100, 150, 200, 250, 300]}
tickValueFormatter={({ value }: BandFillColorAccessorInput) => String(value)}
Expand Down
1 change: 1 addition & 0 deletions storybook/stories/goal/16_two_thirds.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const Example = () => (
base={0}
target={260}
actual={280}
domain={{ min: 0, max: 300 }}
bands={[200, 250, 300]}
ticks={[0, 50, 100, 150, 200, 250, 300]}
tickValueFormatter={({ value }: BandFillColorAccessorInput) => String(value)}
Expand Down
1 change: 1 addition & 0 deletions storybook/stories/goal/17_three_quarters.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const Example = () => (
base={0}
target={260}
actual={280}
domain={{ min: 0, max: 300 }}
bands={[200, 250, 300]}
ticks={[0, 50, 100, 150, 200, 250, 300]}
tickValueFormatter={({ value }: BandFillColorAccessorInput) => String(value)}
Expand Down
1 change: 1 addition & 0 deletions storybook/stories/goal/17_total_circle.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const Example = () => {
base={0}
target={260}
actual={280}
domain={{ min: 0, max: 300 }}
bands={[200, 250, 300]}
ticks={[0, 50, 100, 150, 200, 250, 265, 280]}
tickValueFormatter={({ value }) => String(value)}
Expand Down
1 change: 1 addition & 0 deletions storybook/stories/goal/17_very_small_gap.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const Example = () => (
base={0}
target={260}
actual={280}
domain={{ min: 0, max: 300 }}
bands={[200, 250, 300]}
ticks={[0, 50, 100, 150, 200, 250, 265, 280]}
tickValueFormatter={({ value }: BandFillColorAccessorInput) => String(value)}
Expand Down
1 change: 1 addition & 0 deletions storybook/stories/goal/18_side_gauge.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const Example = () => (
base={0}
target={260}
actual={280}
domain={{ min: 0, max: 300 }}
bands={[200, 250, 300]}
ticks={[0, 50, 100, 150, 200, 250, 300]}
tickValueFormatter={({ value }: BandFillColorAccessorInput) => String(value)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const Example = () => (
base={0}
target={260}
actual={280}
domain={{ min: 0, max: 300 }}
bands={[200, 250, 300]}
ticks={[0, 50, 100, 150, 200, 250, 300]}
tickValueFormatter={({ value }: BandFillColorAccessorInput) => String(value)}
Expand Down
1 change: 1 addition & 0 deletions storybook/stories/goal/19_horizontal_negative.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const Example = () => (
base={0}
target={-260}
actual={-280}
domain={{ min: 0, max: 300 }}
bands={[-200, -250, -300]}
ticks={[0, -50, -100, -150, -200, -250, -300]}
tickValueFormatter={({ value }: BandFillColorAccessorInput) => String(value)}
Expand Down
1 change: 1 addition & 0 deletions storybook/stories/goal/20_vertical_negative.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const Example = () => (
base={0}
target={-260}
actual={-280}
domain={{ min: 0, max: 300 }}
bands={[-200, -250, -300]}
ticks={[0, -50, -100, -150, -200, -250, -300]}
tickValueFormatter={({ value }: BandFillColorAccessorInput) => String(value)}
Expand Down
Loading