Skip to content

Commit

Permalink
feat(goal): auto generated linear ticks (elastic#1637)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: goal chart now requires domain min and max to be defined
  • Loading branch information
nickofthyme committed Apr 7, 2022
1 parent 34bf325 commit 5437d8e
Show file tree
Hide file tree
Showing 41 changed files with 231 additions and 21 deletions.
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.
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])('goal - reverse %p', async (reverse) => {
await common.expectChartAtUrlToMatchScreenshot(
`http://localhost:9001/?path=/story/goal-alpha--auto-linear-ticks&knob-subtype=goal&knob-reverse=${reverse}`,
);
});
});

describe('sagitta shifted goal charts', () => {
it.each<[title: string, startAngle: number, endAngle: number]>([
// top openings
Expand Down
13 changes: 9 additions & 4 deletions packages/charts/api/charts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,12 @@ 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 {
max: number;
min: number;
}

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

Expand All @@ -978,8 +984,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 +993,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 +1006,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));
}
26 changes: 24 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,18 @@ export type BandFillColorAccessor = (input: BandFillColorAccessorInput) => Color
/** @alpha */
export type GoalLabelAccessor = LabelAccessor<BandFillColorAccessorInput>;

/** @alpha */
export interface GoalDomainRange {
/**
* A finite number to defined the lower bound of the domain. Defaults to 0 if _not_ finite.
*/
min: number;
/**
* A finite number to defined the upper bound of the domain. Defaults to 1 if _not_ finite.
*/
max: number;
}

/** @alpha */
export interface GoalSpec extends Spec {
specType: typeof SpecType.Series;
Expand All @@ -45,8 +57,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 of goal charts. Limits every value to within domain.
*/
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: -300, max: 0 }}
bands={[-200, -250, -300]}
ticks={[0, -50, -100, -150, -200, -250, -300]}
tickValueFormatter={({ value }: BandFillColorAccessorInput) => String(value)}
Expand Down
Loading

0 comments on commit 5437d8e

Please sign in to comment.