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: re-type vx/scale with new functionalities #766

Merged
merged 42 commits into from
Jul 24, 2020
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
047140d
feat: update vx scale types
kristw Jul 17, 2020
26277e6
feat: add types and factory for all scales
kristw Jul 18, 2020
7201124
fix: correctly type inputs
kristw Jul 21, 2020
cc311e1
refactor: update input types and add overload
kristw Jul 21, 2020
a2e83f0
fix: fallback to linear scale
kristw Jul 21, 2020
707e87b
fix: docs
kristw Jul 21, 2020
81408f3
fix: overload
kristw Jul 21, 2020
a7ab837
refactor: add DefaultThresholdInput
kristw Jul 21, 2020
c52affc
fix: generic type
kristw Jul 22, 2020
48e2c4a
refactor: simplify overloading
kristw Jul 22, 2020
c5a718c
fix: omit type from config and remove scale type
kristw Jul 22, 2020
21c738a
refactor: update scale
kristw Jul 22, 2020
bbebebf
feat: use operator
kristw Jul 23, 2020
b57bc94
refactor: use operator for all
kristw Jul 23, 2020
783e56a
test: fix unit test
kristw Jul 23, 2020
28a3783
fix: broken tests
kristw Jul 23, 2020
56e0a47
fix: violin type
kristw Jul 23, 2020
f5fb093
fix: symlog round
kristw Jul 23, 2020
7f45b41
docs: update comment
kristw Jul 23, 2020
41f4229
fix: update generic
kristw Jul 23, 2020
8d772a5
feat: add inferScaleType and export types
kristw Jul 23, 2020
c948e0e
fix: domain and range
kristw Jul 23, 2020
6ef370d
test: add unit tests
kristw Jul 23, 2020
bad21bb
test: mock timezone
kristw Jul 23, 2020
a0a24c2
fix: rename export and remove extends value
kristw Jul 23, 2020
deb50d6
refactor: rename value to default output
kristw Jul 23, 2020
aabe8e3
docs: update comment
kristw Jul 23, 2020
32cee04
fix: better undefined check
kristw Jul 23, 2020
dc8786f
fix: type
kristw Jul 23, 2020
653693d
fix: enforce order
kristw Jul 23, 2020
90a5fee
test: add unit test for scales
kristw Jul 23, 2020
eff7091
test: add createScale test
kristw Jul 23, 2020
820c706
test: more tests
kristw Jul 23, 2020
2c7152e
test: more tests
kristw Jul 24, 2020
d9d21f2
fix: make config optional for factories
kristw Jul 24, 2020
e9eb41c
test: more unit tests
kristw Jul 24, 2020
9e24d65
test: 100% statement coverage
kristw Jul 24, 2020
70a9a66
refactor: simplify updateScale
kristw Jul 24, 2020
5c2f2e9
test: last unit test
kristw Jul 24, 2020
fe8d0b2
test: zero test
kristw Jul 24, 2020
a13314a
docs: update warning text
kristw Jul 24, 2020
27f6836
docs: add comment
kristw Jul 24, 2020
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
6 changes: 4 additions & 2 deletions packages/vx-scale/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,17 @@
"visualizations",
"charts"
],
"author": "@hshoff",
"authors": ["@hshoff", "@kristw"],
"license": "MIT",
"bugs": {
"url": "https://github.com/hshoff/vx/issues"
},
"homepage": "https://github.com/hshoff/vx#readme",
"dependencies": {
"@types/d3-scale": "^2.1.1",
"d3-scale": "^2.2.2"
"@types/d3-interpolate": "^1.3.1",
"d3-scale": "^3.0.1",
"d3-interpolate": "^1.4.0"
},
"publishConfig": {
"access": "public"
Expand Down
4 changes: 3 additions & 1 deletion packages/vx-scale/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ export { default as scaleQuantize } from './scales/quantize';
export { default as scaleQuantile } from './scales/quantile';
export { default as scaleSymlog } from './scales/symlog';
export { default as scaleThreshold } from './scales/threshold';
export { default as updateScale } from './util/updateScale';
export { default as scaleSqrt } from './scales/squareRoot';

// Will change
export { default as updateScale } from './util/updateScale';
15 changes: 15 additions & 0 deletions packages/vx-scale/src/mixins/applyInterpolate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { InterpolatorFactory } from 'd3-scale';
import { Value } from '../types/Base';
import { D3Scale } from '../types/Scale';
import { ScaleInterpolate, ScaleInterpolateParams } from '../types/ScaleInterpolate';
import createColorInterpolator from '../util/createColorInterpolator';

export default function applyInterpolate<Output extends Value>(
scale: D3Scale<Output>,
config: { interpolate?: ScaleInterpolate | ScaleInterpolateParams },
) {
if (config.interpolate && 'interpolate' in scale) {
const interpolator = createColorInterpolator(config.interpolate);
scale.interpolate((interpolator as unknown) as InterpolatorFactory<Output, Output>);
}
}
18 changes: 18 additions & 0 deletions packages/vx-scale/src/mixins/applyRound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { interpolateRound } from 'd3-interpolate';
import { InterpolatorFactory } from 'd3-scale';
import { Value, StringLike } from '../types/Base';
import { D3Scale } from '../types/Scale';

export default function applyRound<
Output extends Value,
DiscreteInput extends StringLike = StringLike,
ThresholdInput extends number | string | Date = number | string | Date
>(scale: D3Scale<Output, DiscreteInput, ThresholdInput>, config: { round?: boolean }) {
if (typeof config.round !== 'undefined') {
if ('round' in scale) {
scale.round(config.round);
} else if ('interpolate' in scale && config.round) {
scale.interpolate((interpolateRound as unknown) as InterpolatorFactory<Output, Output>);
}
}
}
16 changes: 16 additions & 0 deletions packages/vx-scale/src/mixins/applyZero.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Value } from '../types/Base';
import { PickD3Scale } from '../types/Scale';

export default function applyZero<Output extends Value>(
scale: PickD3Scale<'linear' | 'pow' | 'sqrt' | 'symlog' | 'quantize', Output>,
config: { zero?: boolean },
) {
if (config.zero === true) {
const domain = scale.domain() as number[];
const [a, b] = domain;
const isDescending = b < a;
const [min, max] = isDescending ? [b, a] : [a, b];
const domainWithZero = [Math.min(0, min), Math.max(0, max)];
scale.domain(isDescending ? domainWithZero.reverse() : domainWithZero);
}
}
67 changes: 26 additions & 41 deletions packages/vx-scale/src/scales/band.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,36 @@
import { scaleBand } from 'd3-scale';
import { Value, StringLike } from '../types/Base';
import { PickScaleConfigWithoutType } from '../types/ScaleConfig';
import { PickD3Scale } from '../types/Scale';
import applyRound from '../mixins/applyRound';

type StringLike = string | { toString(): string };
type Numeric = number | { valueOf(): number };
export function updateBandScale<
DiscreteInput extends StringLike = StringLike,
Output extends Value = Value
>(
scale: PickD3Scale<'band', Output, DiscreteInput>,
config: PickScaleConfigWithoutType<'band', Output, DiscreteInput>,
) {
const { align, domain, padding, paddingInner, paddingOuter, range } = config;

export type BandConfig<Datum extends StringLike> = {
/** Sets the output values of the scale, which are numbers for band scales. */
range?: [Numeric, Numeric];
/** Sets the output values of the scale while setting its interpolator to round. If the elements are not numbers, they will be coerced to numbers. */
rangeRound?: [Numeric, Numeric];
/** Sets the input values of the scale, which are strings for band scales. */
domain?: Datum[];
/** 0-1, determines how any leftover unused space in the range is distributed. 0.5 distributes it equally left and right. */
align?: number;
/** 0-1, determines the ratio of the range that is reserved for blank space before the first point and after the last. */
padding?: number;
/** 0-1, determines the ratio of the range that is reserved for blank space _between_ bands. */
paddingInner?: number;
/** 0-1, determines the ratio of the range that is reserved for blank space before the first band and after the last band. */
paddingOuter?: number;
tickFormat?: unknown;
};

export default function bandScale<Datum extends StringLike = StringLike>({
range,
rangeRound,
domain,
padding,
paddingInner,
paddingOuter,
align,
tickFormat,
}: BandConfig<Datum>) {
const scale = scaleBand<Datum>();

if (range) scale.range(range);
if (rangeRound) scale.rangeRound(rangeRound);
if (domain) scale.domain(domain);
if (padding) scale.padding(padding);
if (paddingInner) scale.paddingInner(paddingInner);
if (paddingOuter) scale.paddingOuter(paddingOuter);
if (align) scale.align(align);
if (range) scale.range(range);
if (typeof padding !== 'undefined') scale.padding(padding);
if (typeof paddingInner !== 'undefined') scale.paddingInner(paddingInner);
if (typeof paddingOuter !== 'undefined') scale.paddingOuter(paddingOuter);
if (typeof align !== 'undefined') scale.align(align);
applyRound(scale, config);

// @TODO should likely get rid of these.
// @ts-ignore
if (tickFormat) scale.tickFormat = tickFormat;
// TODO: Remove?
// @ts-ignore
scale.type = 'band';

return scale;
}

export default function createBandScale<
DiscreteInput extends StringLike = StringLike,
Output extends Value = Value
>(config: PickScaleConfigWithoutType<'band', Output, DiscreteInput>) {
return updateBandScale(scaleBand<DiscreteInput>(), config);
}
48 changes: 24 additions & 24 deletions packages/vx-scale/src/scales/linear.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,35 @@
import { scaleLinear } from 'd3-scale';
import { Value } from '../types/Base';
import { PickScaleConfigWithoutType } from '../types/ScaleConfig';
import { PickD3Scale } from '../types/Scale';
import applyInterpolate from '../mixins/applyInterpolate';
import applyRound from '../mixins/applyRound';
import applyZero from '../mixins/applyZero';

export type LinearConfig<Output> = {
/** Sets the input values of the scale, which are numbers for a linear scale. */
domain?: number[];
/** Sets the output values of the scale. */
range?: Output[];
/** Sets the output values of the scale while setting its interpolator to round. If the elements are not numbers, they will be coerced to numbers. */
rangeRound?: number[];
/** Extends the domain so that it starts and ends on nice round values. */
nice?: boolean;
/** Whether the scale should clamp values to within the range. */
clamp?: boolean;
};
export function updateLinearScale<Output extends Value = Value>(
scale: PickD3Scale<'linear', Output>,
config: PickScaleConfigWithoutType<'linear', Output>,
) {
const { domain, range, clamp = true, nice = true } = config;
hshoff marked this conversation as resolved.
Show resolved Hide resolved

export default function linearScale<Output>({
range,
rangeRound,
domain,
nice = false,
clamp = false,
}: LinearConfig<Output>) {
const scale = scaleLinear<Output>();

if (range) scale.range(range);
if (rangeRound) scale.rangeRound(rangeRound);
if (domain) scale.domain(domain);
if (nice) scale.nice();
if (clamp) scale.clamp(true);
if (range) scale.range(range);

scale.clamp(clamp);
applyInterpolate(scale, config);
applyRound(scale, config);
applyZero(scale, config);
Copy link
Member

Choose a reason for hiding this comment

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

Since these are mutable functions, is call order significant here? Does applyInterpolate() have to be called before applyRound()? If so, we might want to think of a way to enforce this. I don't expect it to a problem as it is lib code, but could be an easily introduced bug that could slip through a review.

Copy link
Collaborator Author

@kristw kristw Jul 21, 2020

Choose a reason for hiding this comment

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

Thank you for the comments. I think the order of operations is a valid point. I could set up a proxy function and only allow a single apply call with list of operations through this proxy to ensure order. Not sure if that is an overkill.

Since these are mutable functions, is call order significant here?

The ones that really have to be in order are domain > nice > zero.

Does applyInterpolate() have to be called before applyRound()?

Not necessary. They should be mutually exclusive. applyInterpolate() is in fact applyColorInterpolate. (This is a new functionality for vx. I could also drop it. Previously I don't think vx was accepting interpolate.)
Users can either do rounding which will set interpolateRound

when a scale output is number.

{ round: true }

or

when a scale output is color and they want to set color interpolation to HCL

{ interpolate: 'hcl' }

It is still not super clean and the typing may allow something like this to be specified. Although both effect cannot happen at the same time.

{ interpolate: 'hcl', round: true }

Alternatives

  1. I could drop the interpolate feature for now.
  2. Stricter typing to allow only either { interpolate } or { round }. Can be complicated.
  3. Make { round: true } become { interpolate: 'round' }.

Copy link
Member

Choose a reason for hiding this comment

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

One idea instead of proxy (can copy & paste in browser console):

const log = (str) => (scale, config) => console.log(str);

const operations = {
  domain: log('domain'),
  nice: log('nice'),
  interpolate: log('interpolate'),
  round: (scale, config) => config.interpolate ? console.warn(`can't round and interpolate`) : console.log('round'),
  zero: log('zero')
};

const order = Object.keys(operations);
const intersection = (a,b) => a.filter(v => b.includes(v));

function scaleOperator(...ops) {
  const orderedOps = intersection(order, ops);
  return (scale, config) => {
    return orderedOps.forEach(op => operations[op](scale, config))
  }
}

// order doesn't matter
const applyOperator1 = scaleOperator('round', 'zero', 'domain', 'nice');
const applyOperator2 = scaleOperator('round', 'zero', 'domain', 'interpolate');

// in createScale({ scale, config })
applyOperator1(/**scale*/ {}, /**config*/ {});
applyOperator2(/**scale*/ {}, /**config*/ { round: true, interpolate: true });
output 1: 
=> domain
=> nice
=> round
=> zero

output 2: 
=> domain
=> interpolate
=> WARN: can't round and interpolate
=> zero

Copy link
Collaborator Author

@kristw kristw Jul 23, 2020

Choose a reason for hiding this comment

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

I refactor with this idea. The individual scale files now look very clean.


// TODO: Remove?
// @ts-ignore
scale.type = 'linear';

return scale;
}

export default function createLinearScale<Output extends Value = Value>(
config: PickScaleConfigWithoutType<'linear', Output>,
) {
return updateLinearScale(scaleLinear<Output>(), config);
}
50 changes: 22 additions & 28 deletions packages/vx-scale/src/scales/log.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,33 @@
import { scaleLog } from 'd3-scale';
import { Value } from '../types/Base';
import { PickD3Scale } from '../types/Scale';
import { PickScaleConfigWithoutType } from '../types/ScaleConfig';
import applyInterpolate from '../mixins/applyInterpolate';
import applyRound from '../mixins/applyRound';

export type LogConfig<Output> = {
/** Sets the input values of the scale, which are numbers for a log scale. */
domain?: number[];
/** Sets the output values of the scale. */
range?: Output[];
/** Sets the output values of the scale while setting its interpolator to round. If the elements are not numbers, they will be coerced to numbers. */
rangeRound?: number[];
/** Sets the base for this logarithmic scale (defaults to 10). */
base?: number;
/** Extends the domain so that it starts and ends on nice round values. */
nice?: boolean;
/** Whether the scale should clamp values to within the range. */
clamp?: boolean;
};
export function updateLogScale<Output extends Value = Value>(
scale: PickD3Scale<'log', Output>,
config: PickScaleConfigWithoutType<'log', Output>,
) {
const { domain, range, base, clamp = true, nice = true } = config;

export default function logScale<Output>({
range,
rangeRound,
domain,
base,
nice = false,
clamp = false,
}: LogConfig<Output>) {
const scale = scaleLog<Output>();

if (range) scale.range(range);
if (rangeRound) scale.rangeRound(rangeRound);
if (base) scale.base(base);
if (domain) scale.domain(domain);
if (nice) scale.nice();
if (clamp) scale.clamp(true);
if (base) scale.base(base);
if (range) scale.range(range);

scale.clamp(clamp);
applyInterpolate(scale, config);
applyRound(scale, config);

// @ts-ignore
scale.type = 'log';

return scale;
}

export default function createLogScale<Output extends Value = Value>(
config: PickScaleConfigWithoutType<'log', Output>,
) {
return updateLogScale(scaleLog<Output>(), config);
}
36 changes: 20 additions & 16 deletions packages/vx-scale/src/scales/ordinal.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
import { scaleOrdinal } from 'd3-scale';
import { Value, StringLike } from '../types/Base';
import { PickScaleConfigWithoutType } from '../types/ScaleConfig';
import { PickD3Scale } from '../types/Scale';

export type OrdinalConfig<Input, Output> = {
/** Sets the input values of the scale, which are strings for an ordinal scale. */
domain?: Input[];
/** Sets the output values of the scale. */
range?: Output[];
/** Sets the output value of the scale for unknown input values. */
unknown?: Output | { name: 'implicit' };
};
export function updateOrdinalScale<
DiscreteInput extends StringLike = StringLike,
Output extends Value = Value
>(
scale: PickD3Scale<'ordinal', Output, DiscreteInput>,
config: PickScaleConfigWithoutType<'ordinal', Output, DiscreteInput>,
) {
const { domain, range, unknown } = config;

export default function ordinalScale<Input extends { toString(): string }, Output>({
range,
domain,
unknown,
}: OrdinalConfig<Input, Output>) {
const scale = scaleOrdinal<Input, Output>();

if (range) scale.range(range);
if (domain) scale.domain(domain);
if (range) scale.range(range);
if (unknown) scale.unknown(unknown);

// TODO: Remove?
// @ts-ignore
scale.type = 'ordinal';

return scale;
}

export default function createOrdinalScale<
DiscreteInput extends StringLike = StringLike,
Output extends Value = Value
>(config: PickScaleConfigWithoutType<'ordinal', Output, DiscreteInput>) {
return updateOrdinalScale(scaleOrdinal<DiscreteInput, Output>(), config);
}
49 changes: 24 additions & 25 deletions packages/vx-scale/src/scales/point.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,34 @@
import { scalePoint } from 'd3-scale';
import { Value, StringLike } from '../types/Base';
import { PickScaleConfigWithoutType } from '../types/ScaleConfig';
import { PickD3Scale } from '../types/Scale';
import applyRound from '../mixins/applyRound';

export type PointConfig<Input> = {
/** Sets the output values of the scale, which are numbers for point scales. */
range?: [number, number];
/** Sets the output values of the scale while setting its interpolator to round. */
rangeRound?: [number, number];
/** Sets the input values of the scale. */
domain?: Input[];
/** 0-1, determines the ratio of the range that is reserved for blank space before the first point and after the last. */
padding?: number;
/** 0-1, determines how any leftover unused space in the range is distributed. 0.5 distributes it equally left and right. */
align?: number;
};
export function updatePointScale<
DiscreteInput extends StringLike = StringLike,
Output extends Value = Value
>(
scale: PickD3Scale<'point', Output, DiscreteInput>,
config: PickScaleConfigWithoutType<'point', Output, DiscreteInput>,
) {
const { align, domain, padding, range } = config;

export default function pointScale<Input>({
range,
rangeRound,
domain,
padding,
align,
}: PointConfig<Input>) {
const scale = scalePoint<Input>();

if (range) scale.range(range);
if (rangeRound) scale.rangeRound(rangeRound);
if (domain) scale.domain(domain);
if (padding) scale.padding(padding);
if (align) scale.align(align);
if (range) scale.range(range);
if (typeof padding !== 'undefined') scale.padding(padding);
if (typeof align !== 'undefined') scale.align(align);
applyRound(scale, config);

// TODO: Remove?
// @ts-ignore
scale.type = 'point';

return scale;
}

export default function createPointScale<
DiscreteInput extends StringLike = StringLike,
Output extends Value = Value
>(config: PickScaleConfigWithoutType<'point', Output, DiscreteInput>) {
return updatePointScale(scalePoint<DiscreteInput>(), config);
}
Loading