Skip to content

Commit f971d05

Browse files
authored
fix(scales): use bisect to handle invertWithStep (#200)
Use a d3 bisect function on invertWithStep instead of relying on the minInterval and bandwidth values. This enables the use of irregular intervals as dataset for linear/time x axis fix #195, fix #183
1 parent e11ec6a commit f971d05

13 files changed

+337
-154
lines changed

src/lib/utils/scales/scale_band.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ScaleBand } from './scale_band';
22

3-
describe.only('Scale Band', () => {
3+
describe('Scale Band', () => {
44
it('shall clone domain and range arrays', () => {
55
const domain = [0, 1, 2, 3];
66
const range = [0, 100] as [number, number];
@@ -79,7 +79,12 @@ describe.only('Scale Band', () => {
7979
expect(scale2.scale(3)).toBe(81.25);
8080
// an empty 1/2 step place at the end
8181
});
82-
test('shall invert all values in range', () => {
82+
it('shall not scale scale null values', () => {
83+
const scale = new ScaleBand([0, 1, 2], [0, 120], undefined, 0.5);
84+
expect(scale.scale(-1)).toBeUndefined();
85+
expect(scale.scale(3)).toBeUndefined();
86+
});
87+
it('shall invert all values in range', () => {
8388
const domain = ['a', 'b', 'c', 'd'];
8489
const minRange = 0;
8590
const maxRange = 100;

src/lib/utils/scales/scale_band.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { scaleBand, scaleQuantize, ScaleQuantize } from 'd3-scale';
22
import { clamp } from '../commons';
3-
import { StepType } from './scale_continuous';
43
import { ScaleType } from './scales';
54
import { Scale } from './scales';
65

@@ -61,7 +60,7 @@ export class ScaleBand implements Scale {
6160
invert(value: any) {
6261
return this.invertedScale(value);
6362
}
64-
invertWithStep(value: any, stepType?: StepType) {
63+
invertWithStep(value: any) {
6564
return this.invertedScale(value);
6665
}
6766
}

src/lib/utils/scales/scale_continuous.test.ts

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { DateTime } from 'luxon';
2+
import { XDomain } from '../../series/domains/x_domain';
3+
import { computeXScale } from '../../series/scales';
4+
import { Domain } from '../domain';
25
import { ScaleBand } from './scale_band';
36
import { isLogarithmicScale, ScaleContinuous } from './scale_continuous';
47
import { ScaleType } from './scales';
58

69
describe('Scale Continuous', () => {
710
test('shall invert on continuous scale linear', () => {
8-
const domain = [0, 2];
11+
const domain: Domain = [0, 2];
912
const minRange = 0;
1013
const maxRange = 100;
1114
const scale = new ScaleContinuous(ScaleType.Linear, domain, [minRange, maxRange]);
@@ -26,7 +29,7 @@ describe('Scale Continuous', () => {
2629
expect(scale.invert(100)).toBe(endTime.toMillis());
2730
});
2831
test('check if a scale is log scale', () => {
29-
const domain = [0, 2];
32+
const domain: Domain = [0, 2];
3033
const range: [number, number] = [0, 100];
3134
const scaleLinear = new ScaleContinuous(ScaleType.Linear, domain, range);
3235
const scaleLog = new ScaleContinuous(ScaleType.Log, domain, range);
@@ -39,4 +42,101 @@ describe('Scale Continuous', () => {
3942
expect(isLogarithmicScale(scaleSqrt)).toBe(false);
4043
expect(isLogarithmicScale(scaleBand)).toBe(false);
4144
});
45+
test('can get the right x value on linear scale', () => {
46+
const domain: Domain = [0, 2];
47+
const data = [0, 0.5, 0.8, 2];
48+
const range: [number, number] = [0, 2];
49+
const scaleLinear = new ScaleContinuous(ScaleType.Linear, domain, range);
50+
expect(scaleLinear.bandwidth).toBe(0);
51+
expect(scaleLinear.invertWithStep(0, data)).toBe(0);
52+
expect(scaleLinear.invertWithStep(0.1, data)).toBe(0);
53+
54+
expect(scaleLinear.invertWithStep(0.4, data)).toBe(0.5);
55+
expect(scaleLinear.invertWithStep(0.5, data)).toBe(0.5);
56+
expect(scaleLinear.invertWithStep(0.6, data)).toBe(0.5);
57+
58+
expect(scaleLinear.invertWithStep(0.7, data)).toBe(0.8);
59+
expect(scaleLinear.invertWithStep(0.8, data)).toBe(0.8);
60+
expect(scaleLinear.invertWithStep(0.9, data)).toBe(0.8);
61+
62+
expect(scaleLinear.invertWithStep(2, data)).toBe(2);
63+
64+
expect(scaleLinear.invertWithStep(1.7, data)).toBe(2);
65+
66+
expect(scaleLinear.invertWithStep(0.8 + (2 - 0.8) / 2, data)).toBe(0.8);
67+
expect(scaleLinear.invertWithStep(0.8 + (2 - 0.8) / 2 - 0.01, data)).toBe(0.8);
68+
69+
expect(scaleLinear.invertWithStep(0.8 + (2 - 0.8) / 2 + 0.01, data)).toBe(2);
70+
});
71+
test('invert with step x value on linear band scale', () => {
72+
const data = [0, 1, 2];
73+
const xDomain: XDomain = {
74+
domain: [0, 2],
75+
isBandScale: true,
76+
minInterval: 1,
77+
scaleType: ScaleType.Linear,
78+
type: 'xDomain',
79+
};
80+
81+
const scaleLinear = computeXScale(xDomain, 1, 0, 120, 0);
82+
expect(scaleLinear.bandwidth).toBe(40);
83+
expect(scaleLinear.invertWithStep(0, data)).toBe(0);
84+
expect(scaleLinear.invertWithStep(40, data)).toBe(1);
85+
86+
expect(scaleLinear.invertWithStep(41, data)).toBe(1);
87+
expect(scaleLinear.invertWithStep(79, data)).toBe(1);
88+
89+
expect(scaleLinear.invertWithStep(80, data)).toBe(2);
90+
expect(scaleLinear.invertWithStep(81, data)).toBe(2);
91+
expect(scaleLinear.invertWithStep(120, data)).toBe(2);
92+
});
93+
test('can get the right x value on linear scale with regular band 1', () => {
94+
const domain = [0, 100];
95+
const data = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90];
96+
97+
// we tweak the maxRange removing the bandwidth to correctly compute
98+
// a band linear scale in computeXScale
99+
const range: [number, number] = [0, 100 - 10];
100+
const scaleLinear = new ScaleContinuous(ScaleType.Linear, domain, range, 10, 10);
101+
expect(scaleLinear.bandwidth).toBe(10);
102+
expect(scaleLinear.invertWithStep(0, data)).toBe(0);
103+
expect(scaleLinear.invertWithStep(10, data)).toBe(10);
104+
expect(scaleLinear.invertWithStep(20, data)).toBe(20);
105+
expect(scaleLinear.invertWithStep(90, data)).toBe(90);
106+
});
107+
test('can get the right x value on linear scale with band', () => {
108+
const data = [0, 10, 20, 50, 90];
109+
// we tweak the maxRange removing the bandwidth to correctly compute
110+
// a band linear scale in computeXScale
111+
112+
const xDomain: XDomain = {
113+
domain: [0, 100],
114+
isBandScale: true,
115+
minInterval: 10,
116+
scaleType: ScaleType.Linear,
117+
type: 'xDomain',
118+
};
119+
120+
const scaleLinear = computeXScale(xDomain, 1, 0, 110, 0);
121+
// const scaleLinear = new ScaleContinuous(ScaleType.Linear, domain, range, 10, 10);
122+
expect(scaleLinear.bandwidth).toBe(10);
123+
124+
expect(scaleLinear.invertWithStep(0, data)).toBe(0);
125+
expect(scaleLinear.invertWithStep(5, data)).toBe(0);
126+
expect(scaleLinear.invertWithStep(9, data)).toBe(0);
127+
128+
expect(scaleLinear.invertWithStep(10, data)).toBe(10);
129+
expect(scaleLinear.invertWithStep(11, data)).toBe(10);
130+
expect(scaleLinear.invertWithStep(19, data)).toBe(10);
131+
132+
expect(scaleLinear.invertWithStep(20, data)).toBe(20);
133+
expect(scaleLinear.invertWithStep(21, data)).toBe(20);
134+
expect(scaleLinear.invertWithStep(25, data)).toBe(20);
135+
expect(scaleLinear.invertWithStep(29, data)).toBe(20);
136+
expect(scaleLinear.invertWithStep(30, data)).toBe(20);
137+
expect(scaleLinear.invertWithStep(39, data)).toBe(20);
138+
expect(scaleLinear.invertWithStep(40, data)).toBe(50);
139+
expect(scaleLinear.invertWithStep(50, data)).toBe(50);
140+
expect(scaleLinear.invertWithStep(90, data)).toBe(90);
141+
});
42142
});

src/lib/utils/scales/scale_continuous.ts

Lines changed: 30 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { bisectLeft } from 'd3-array';
12
import { scaleLinear, scaleLog, scaleSqrt, scaleUtc } from 'd3-scale';
23
import { DateTime } from 'luxon';
34
import { clamp } from '../commons';
@@ -60,11 +61,6 @@ export function limitLogScaleDomain(domain: any[]) {
6061
}
6162
return domain;
6263
}
63-
export enum StepType {
64-
StepBefore = 'before',
65-
StepAfter = 'after',
66-
Step = 'half',
67-
}
6864

6965
export class ScaleContinuous implements Scale {
7066
readonly bandwidth: number;
@@ -149,7 +145,7 @@ export class ScaleContinuous implements Scale {
149145
});
150146
} else {
151147
if (this.minInterval > 0) {
152-
const intervalCount = (this.domain[1] - this.domain[0]) / this.minInterval;
148+
const intervalCount = Math.floor((this.domain[1] - this.domain[0]) / this.minInterval);
153149
this.tickValues = new Array(intervalCount + 1).fill(0).map((d, i) => {
154150
return this.domain[0] + i * this.minInterval;
155151
});
@@ -173,10 +169,34 @@ export class ScaleContinuous implements Scale {
173169
}
174170
return invertedValue;
175171
}
176-
invertWithStep(value: number, stepType?: StepType) {
177-
const invertedValue = this.invert(value);
178-
const forcedStep = this.bandwidth > 0 ? StepType.StepAfter : stepType;
179-
return invertValue(this.domain[0], invertedValue, this.minInterval, forcedStep);
172+
invertWithStep(value: number, data: number[]): any {
173+
const invertedValue = this.invert(value - this.bandwidth / 2);
174+
const leftIndex = bisectLeft(data, invertedValue);
175+
if (leftIndex === 0) {
176+
// is equal or less than the first value
177+
const prevValue1 = data[leftIndex];
178+
if (data.length === 0) {
179+
return prevValue1;
180+
}
181+
const nextValue1 = data[leftIndex + 1];
182+
const nextDiff1 = Math.abs(nextValue1 - invertedValue);
183+
const prevDiff1 = Math.abs(invertedValue - prevValue1);
184+
if (nextDiff1 < prevDiff1) {
185+
return nextValue1;
186+
}
187+
return prevValue1;
188+
}
189+
if (leftIndex === data.length) {
190+
return data[leftIndex - 1];
191+
}
192+
const nextValue = data[leftIndex];
193+
const prevValue = data[leftIndex - 1];
194+
const nextDiff = Math.abs(nextValue - invertedValue);
195+
const prevDiff = Math.abs(invertedValue - prevValue);
196+
if (nextDiff <= prevDiff) {
197+
return nextValue;
198+
}
199+
return prevValue;
180200
}
181201
}
182202

@@ -187,58 +207,3 @@ export function isContinuousScale(scale: Scale): scale is ScaleContinuous {
187207
export function isLogarithmicScale(scale: Scale) {
188208
return scale.type === ScaleType.Log;
189209
}
190-
191-
function invertValue(
192-
domainMin: number,
193-
invertedValue: number,
194-
minInterval: number,
195-
stepType?: StepType,
196-
) {
197-
if (minInterval > 0) {
198-
switch (stepType) {
199-
case StepType.StepAfter:
200-
return linearStepAfter(invertedValue, minInterval);
201-
case StepType.StepBefore:
202-
return linearStepBefore(invertedValue, minInterval);
203-
case StepType.Step:
204-
default:
205-
return linearStep(domainMin, invertedValue, minInterval);
206-
}
207-
}
208-
return invertedValue;
209-
}
210-
211-
/**
212-
* Return an inverted value that is valid from the exact point of the scale
213-
* till the end of the interval. |--------|********|
214-
* @param invertedValue the inverted value
215-
* @param minInterval the data minimum interval grether than 0
216-
*/
217-
export function linearStepAfter(invertedValue: number, minInterval: number): number {
218-
return Math.floor(invertedValue / minInterval) * minInterval;
219-
}
220-
221-
/**
222-
* Return an inverted value that is valid from the half point before and half point
223-
* after the value. |----****|*****----|
224-
* till the end of the interval.
225-
* @param domainMin the domain's minimum value
226-
* @param invertedValue the inverted value
227-
* @param minInterval the data minimum interval grether than 0
228-
*/
229-
export function linearStep(domainMin: number, invertedValue: number, minInterval: number): number {
230-
const diff = (invertedValue - domainMin) / minInterval;
231-
const base = diff - Math.floor(diff) > 0.5 ? 1 : 0;
232-
return domainMin + Math.floor(diff) * minInterval + minInterval * base;
233-
}
234-
235-
/**
236-
* Return an inverted value that is valid from the half point before and half point
237-
* after the value. |********|--------|
238-
* till the end of the interval.
239-
* @param invertedValue the inverted value
240-
* @param minInterval the data minimum interval grether than 0
241-
*/
242-
export function linearStepBefore(invertedValue: number, minInterval: number): number {
243-
return Math.ceil(invertedValue / minInterval) * minInterval;
244-
}

0 commit comments

Comments
 (0)