Skip to content

Commit 801c767

Browse files
committedSep 11, 2021
feat(scales): move ticks logic to the scales package
1 parent 490b761 commit 801c767

15 files changed

+384
-394
lines changed
 

‎packages/axes/package.json

-2
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,11 @@
3030
"@nivo/scales": "0.73.0",
3131
"@react-spring/web": "9.2.4",
3232
"d3-format": "^1.4.4",
33-
"d3-time": "^1.0.11",
3433
"d3-time-format": "^3.0.0"
3534
},
3635
"devDependencies": {
3736
"@nivo/core": "0.73.0",
3837
"@types/d3-format": "^1.4.1",
39-
"@types/d3-time": "^1.1.1",
4038
"@types/d3-time-format": "^2.3.1"
4139
},
4240
"peerDependencies": {

‎packages/axes/src/canvas.ts

+6-12
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
import { degreesToRadians, CompleteTheme } from '@nivo/core'
2+
import { ScaleValue, AnyScale, TicksSpec } from '@nivo/scales'
23
import { computeCartesianTicks, getFormatter, computeGridLines } from './compute'
34
import { positions } from './props'
4-
import {
5-
AxisValue,
6-
TicksSpec,
7-
AnyScale,
8-
AxisLegendPosition,
9-
CanvasAxisProp,
10-
ValueFormatter,
11-
} from './types'
12-
13-
export const renderAxisToCanvas = <Value extends AxisValue>(
5+
import { AxisLegendPosition, CanvasAxisProp, ValueFormatter } from './types'
6+
7+
export const renderAxisToCanvas = <Value extends ScaleValue>(
148
ctx: CanvasRenderingContext2D,
159
{
1610
axis,
@@ -163,7 +157,7 @@ export const renderAxisToCanvas = <Value extends AxisValue>(
163157
ctx.restore()
164158
}
165159

166-
export const renderAxesToCanvas = <X extends AxisValue, Y extends AxisValue>(
160+
export const renderAxesToCanvas = <X extends ScaleValue, Y extends ScaleValue>(
167161
ctx: CanvasRenderingContext2D,
168162
{
169163
xScale,
@@ -217,7 +211,7 @@ export const renderAxesToCanvas = <X extends AxisValue, Y extends AxisValue>(
217211
})
218212
}
219213

220-
export const renderGridLinesToCanvas = <Value extends AxisValue>(
214+
export const renderGridLinesToCanvas = <Value extends ScaleValue>(
221215
ctx: CanvasRenderingContext2D,
222216
{
223217
width,

‎packages/axes/src/components/Axes.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { memo } from 'react'
2+
import { ScaleValue, AnyScale } from '@nivo/scales'
23
import { Axis } from './Axis'
34
import { positions } from '../props'
4-
import { AnyScale, AxisProps, AxisValue } from '../types'
5+
import { AxisProps } from '../types'
56

67
export const Axes = memo(
7-
<X extends AxisValue, Y extends AxisValue>({
8+
<X extends ScaleValue, Y extends ScaleValue>({
89
xScale,
910
yScale,
1011
width,

‎packages/axes/src/components/Axis.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import { useMemo, memo } from 'react'
22
import * as React from 'react'
33
import { useSpring, useTransition, animated } from '@react-spring/web'
44
import { useTheme, useMotionConfig } from '@nivo/core'
5+
import { ScaleValue, AnyScale } from '@nivo/scales'
56
import { computeCartesianTicks, getFormatter } from '../compute'
67
import { AxisTick } from './AxisTick'
7-
import { AnyScale, AxisProps, AxisValue } from '../types'
8+
import { AxisProps } from '../types'
89

9-
const Axis = <Value extends AxisValue>({
10+
const Axis = <Value extends ScaleValue>({
1011
axis,
1112
scale,
1213
x = 0,

‎packages/axes/src/components/AxisTick.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { useMemo, memo } from 'react'
22
import * as React from 'react'
33
import { animated } from '@react-spring/web'
44
import { useTheme } from '@nivo/core'
5-
import { AxisTickProps, AxisValue } from '../types'
5+
import { ScaleValue } from '@nivo/scales'
6+
import { AxisTickProps } from '../types'
67

7-
const AxisTick = <Value extends AxisValue>({
8+
const AxisTick = <Value extends ScaleValue>({
89
value: _value,
910
format,
1011
lineX,

‎packages/axes/src/components/Grid.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { useMemo, memo } from 'react'
2+
import { ScaleValue, AnyScale, TicksSpec } from '@nivo/scales'
23
import { GridLines } from './GridLines'
34
import { computeGridLines } from '../compute'
4-
import { AnyScale, AxisValue, TicksSpec } from '../types'
55

66
export const Grid = memo(
7-
<X extends AxisValue, Y extends AxisValue>({
7+
<X extends ScaleValue, Y extends ScaleValue>({
88
width,
99
height,
1010
xScale,

‎packages/axes/src/compute.ts

+8-159
Original file line numberDiff line numberDiff line change
@@ -1,164 +1,13 @@
1-
import {
2-
CountableTimeInterval,
3-
timeMillisecond,
4-
utcMillisecond,
5-
timeSecond,
6-
utcSecond,
7-
timeMinute,
8-
utcMinute,
9-
timeHour,
10-
utcHour,
11-
timeWeek,
12-
utcWeek,
13-
timeSunday,
14-
utcSunday,
15-
timeMonday,
16-
utcMonday,
17-
timeTuesday,
18-
utcTuesday,
19-
timeWednesday,
20-
utcWednesday,
21-
timeThursday,
22-
utcThursday,
23-
timeFriday,
24-
utcFriday,
25-
timeSaturday,
26-
utcSaturday,
27-
timeMonth,
28-
utcMonth,
29-
timeYear,
30-
utcYear,
31-
timeInterval,
32-
} from 'd3-time'
331
import { timeFormat } from 'd3-time-format'
342
import { format as d3Format } from 'd3-format'
353
// @ts-ignore
364
import { textPropsByEngine } from '@nivo/core'
37-
import {
38-
AxisValue,
39-
Point,
40-
TicksSpec,
41-
AnyScale,
42-
ScaleWithBandwidth,
43-
ValueFormatter,
44-
Line,
45-
} from './types'
46-
47-
export const centerScale = <Value>(scale: ScaleWithBandwidth) => {
48-
const bandwidth = scale.bandwidth()
49-
50-
if (bandwidth === 0) return scale
51-
52-
let offset = bandwidth / 2
53-
if (scale.round()) {
54-
offset = Math.round(offset)
55-
}
56-
57-
return <T extends Value>(d: T) => (scale(d) ?? 0) + offset
58-
}
59-
60-
const timeDay = timeInterval(
61-
date => date.setHours(0, 0, 0, 0),
62-
(date, step) => date.setDate(date.getDate() + step),
63-
(start, end) => (end.getTime() - start.getTime()) / 864e5,
64-
date => Math.floor(date.getTime() / 864e5)
65-
)
66-
67-
const utcDay = timeInterval(
68-
date => date.setUTCHours(0, 0, 0, 0),
69-
(date, step) => date.setUTCDate(date.getUTCDate() + step),
70-
(start, end) => (end.getTime() - start.getTime()) / 864e5,
71-
date => Math.floor(date.getTime() / 864e5)
72-
)
73-
74-
const timeByType: Record<string, [CountableTimeInterval, CountableTimeInterval]> = {
75-
millisecond: [timeMillisecond, utcMillisecond],
76-
second: [timeSecond, utcSecond],
77-
minute: [timeMinute, utcMinute],
78-
hour: [timeHour, utcHour],
79-
day: [timeDay, utcDay],
80-
week: [timeWeek, utcWeek],
81-
sunday: [timeSunday, utcSunday],
82-
monday: [timeMonday, utcMonday],
83-
tuesday: [timeTuesday, utcTuesday],
84-
wednesday: [timeWednesday, utcWednesday],
85-
thursday: [timeThursday, utcThursday],
86-
friday: [timeFriday, utcFriday],
87-
saturday: [timeSaturday, utcSaturday],
88-
month: [timeMonth, utcMonth],
89-
year: [timeYear, utcYear],
90-
}
91-
92-
const timeTypes = Object.keys(timeByType)
93-
const timeIntervalRegexp = new RegExp(`^every\\s*(\\d+)?\\s*(${timeTypes.join('|')})s?$`, 'i')
94-
95-
const isInteger = (value: unknown): value is number =>
96-
typeof value === 'number' && isFinite(value) && Math.floor(value) === value
5+
import { ScaleValue, AnyScale, TicksSpec, getScaleTicks, centerScale } from '@nivo/scales'
6+
import { Point, ValueFormatter, Line } from './types'
977

988
const isArray = <T>(value: unknown): value is T[] => Array.isArray(value)
999

100-
export const getScaleTicks = <Value extends AxisValue>(
101-
scale: AnyScale,
102-
spec?: TicksSpec<Value>
103-
) => {
104-
// specific values
105-
if (Array.isArray(spec)) {
106-
return spec
107-
}
108-
109-
if (typeof spec === 'string' && 'useUTC' in scale) {
110-
// time interval
111-
const matches = spec.match(timeIntervalRegexp)
112-
113-
if (matches) {
114-
const [, amount, type] = matches
115-
// UTC is used as it's more predictible
116-
// however local time could be used too
117-
// let's see how it fits users' requirements
118-
const timeType = timeByType[type][scale.useUTC ? 1 : 0]
119-
120-
if (type === 'day') {
121-
const [start, originalStop] = scale.domain()
122-
const stop = new Date(originalStop)
123-
124-
// Set range to include last day in the domain since `interval.range` function is exclusive stop
125-
stop.setDate(stop.getDate() + 1)
126-
127-
return timeType.every(Number(amount ?? 1))?.range(start, stop) ?? []
128-
}
129-
130-
if (amount === undefined) {
131-
return scale.ticks(timeType)
132-
}
133-
134-
const interval = timeType.every(Number(amount))
135-
136-
if (interval) {
137-
return scale.ticks(interval)
138-
}
139-
}
140-
141-
throw new Error(`Invalid tickValues: ${spec}`)
142-
}
143-
144-
// continuous scales
145-
if ('ticks' in scale) {
146-
// default behaviour
147-
if (spec === undefined) {
148-
return scale.ticks()
149-
}
150-
151-
// specific tick count
152-
if (isInteger(spec)) {
153-
return scale.ticks(spec)
154-
}
155-
}
156-
157-
// non linear scale default
158-
return scale.domain()
159-
}
160-
161-
export const computeCartesianTicks = <Value extends AxisValue>({
10+
export const computeCartesianTicks = <Value extends ScaleValue>({
16211
axis,
16312
scale,
16413
ticksPosition,
@@ -177,7 +26,7 @@ export const computeCartesianTicks = <Value extends AxisValue>({
17726
tickRotation: number
17827
engine?: 'svg' | 'canvas'
17928
}) => {
180-
const values = getScaleTicks(scale, tickValues)
29+
const values = getScaleTicks<Value>(scale, tickValues)
18130

18231
const textProps = textPropsByEngine[engine]
18332

@@ -245,7 +94,7 @@ export const computeCartesianTicks = <Value extends AxisValue>({
24594
}
24695
}
24796

248-
export const getFormatter = <Value extends AxisValue>(
97+
export const getFormatter = <Value extends ScaleValue>(
24998
format: string | ValueFormatter<Value> | undefined,
25099
scale: AnyScale
251100
): ValueFormatter<Value> | undefined => {
@@ -254,13 +103,13 @@ export const getFormatter = <Value extends AxisValue>(
254103
if (scale.type === 'time') {
255104
const formatter = timeFormat(format)
256105

257-
return (d => formatter(d instanceof Date ? d : new Date(d))) as ValueFormatter<Value>
106+
return ((d: any) => formatter(d instanceof Date ? d : new Date(d))) as ValueFormatter<Value>
258107
}
259108

260109
return (d3Format(format) as unknown) as ValueFormatter<Value>
261110
}
262111

263-
export const computeGridLines = <Value extends AxisValue>({
112+
export const computeGridLines = <Value extends ScaleValue>({
264113
width,
265114
height,
266115
scale,
@@ -274,7 +123,7 @@ export const computeGridLines = <Value extends AxisValue>({
274123
values?: TicksSpec<Value>
275124
}) => {
276125
const lineValues = isArray<number>(_values) ? _values : undefined
277-
const values = lineValues || getScaleTicks(scale, _values)
126+
const values = lineValues || getScaleTicks<Value>(scale, _values)
278127
const position = 'bandwidth' in scale ? centerScale(scale) : scale
279128

280129
const lines: Line[] =

‎packages/axes/src/types.ts

+6-25
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import * as React from 'react'
2-
import { Scale, ScaleBand, ScalePoint } from '@nivo/scales'
2+
import { ScaleValue, TicksSpec } from '@nivo/scales'
33
import { SpringValues } from '@react-spring/web'
44

5-
export type AxisValue = string | number | Date
6-
75
export type GridValuesBuilder<T> = T extends number
86
? number[]
97
: T extends string
@@ -12,34 +10,18 @@ export type GridValuesBuilder<T> = T extends number
1210
? Date[]
1311
: never
1412

15-
export type GridValues<T extends AxisValue> = number | GridValuesBuilder<T>
13+
export type GridValues<T extends ScaleValue> = number | GridValuesBuilder<T>
1614

1715
export type Point = {
1816
x: number
1917
y: number
2018
}
2119

22-
export type ScaleWithBandwidth = ScaleBand<any> | ScalePoint<any>
23-
24-
export type AnyScale = Scale<any, any>
25-
26-
export type TicksSpec<Value extends AxisValue> =
27-
// exact number of ticks, please note that
28-
// depending on the current range of values,
29-
// you might not get this exact count
30-
| number
31-
// string is used for Date based scales,
32-
// it can express a time interval,
33-
// for example: every 2 weeks
34-
| string
35-
// override scale ticks with custom explicit values
36-
| Value[]
37-
3820
export type AxisLegendPosition = 'start' | 'middle' | 'end'
3921

40-
export type ValueFormatter<Value extends AxisValue> = (value: Value) => Value | string
22+
export type ValueFormatter<Value extends ScaleValue> = (value: Value) => Value | string
4123

42-
export interface AxisProps<Value extends AxisValue = any> {
24+
export interface AxisProps<Value extends ScaleValue = any> {
4325
ticksPosition?: 'before' | 'after'
4426
tickValues?: TicksSpec<Value>
4527
tickSize?: number
@@ -53,12 +35,11 @@ export interface AxisProps<Value extends AxisValue = any> {
5335
ariaHidden?: boolean
5436
}
5537

56-
export interface CanvasAxisProp<Value extends string | number | Date>
57-
extends Omit<AxisProps<Value>, 'legend'> {
38+
export interface CanvasAxisProp<Value extends ScaleValue> extends Omit<AxisProps<Value>, 'legend'> {
5839
legend?: string
5940
}
6041

61-
export interface AxisTickProps<Value extends AxisValue> {
42+
export interface AxisTickProps<Value extends ScaleValue> {
6243
tickIndex: number
6344
value: Value
6445
format?: ValueFormatter<Value>

‎packages/axes/tests/compute.test.tsx

+3-187
Original file line numberDiff line numberDiff line change
@@ -1,190 +1,6 @@
1-
import { scaleLinear, scaleOrdinal, scalePoint, scaleBand, scaleTime, scaleUtc } from 'd3-scale'
2-
import { getScaleTicks, computeCartesianTicks } from '../src/compute'
3-
4-
describe('getTicks', () => {
5-
describe('linear scale', () => {
6-
const linearScale = scaleLinear().domain([0, 500])
7-
8-
it('should return default ticks', () => {
9-
expect(getScaleTicks(linearScale)).toEqual([
10-
0,
11-
50,
12-
100,
13-
150,
14-
200,
15-
250,
16-
300,
17-
350,
18-
400,
19-
450,
20-
500,
21-
])
22-
})
23-
24-
it('should support using a count', () => {
25-
expect(getScaleTicks(linearScale, 6)).toEqual([0, 100, 200, 300, 400, 500])
26-
})
27-
28-
it('should support using specific values', () => {
29-
expect(getScaleTicks(linearScale, [0, 200, 400])).toEqual([0, 200, 400])
30-
})
31-
})
32-
33-
describe('time scale', () => {
34-
const timeScale = scaleUtc().domain([
35-
new Date(Date.UTC(2000, 0, 1, 0, 0, 0, 0)),
36-
new Date(Date.UTC(2001, 0, 1, 0, 0, 0, 0)),
37-
])
38-
39-
it('should return default ticks', () => {
40-
expect(getScaleTicks(timeScale)).toEqual([
41-
new Date(Date.UTC(2000, 0, 1, 0, 0, 0, 0)),
42-
new Date(Date.UTC(2000, 1, 1, 0, 0, 0, 0)),
43-
new Date(Date.UTC(2000, 2, 1, 0, 0, 0, 0)),
44-
new Date(Date.UTC(2000, 3, 1, 0, 0, 0, 0)),
45-
new Date(Date.UTC(2000, 4, 1, 0, 0, 0, 0)),
46-
new Date(Date.UTC(2000, 5, 1, 0, 0, 0, 0)),
47-
new Date(Date.UTC(2000, 6, 1, 0, 0, 0, 0)),
48-
new Date(Date.UTC(2000, 7, 1, 0, 0, 0, 0)),
49-
new Date(Date.UTC(2000, 8, 1, 0, 0, 0, 0)),
50-
new Date(Date.UTC(2000, 9, 1, 0, 0, 0, 0)),
51-
new Date(Date.UTC(2000, 10, 1, 0, 0, 0, 0)),
52-
new Date(Date.UTC(2000, 11, 1, 0, 0, 0, 0)),
53-
new Date(Date.UTC(2001, 0, 1, 0, 0, 0, 0)),
54-
])
55-
})
56-
57-
it('should support using a count', () => {
58-
expect(getScaleTicks(timeScale, 4)).toEqual([
59-
new Date(Date.UTC(2000, 0, 1, 0, 0, 0, 0)),
60-
new Date(Date.UTC(2000, 3, 1, 0, 0, 0, 0)),
61-
new Date(Date.UTC(2000, 6, 1, 0, 0, 0, 0)),
62-
new Date(Date.UTC(2000, 9, 1, 0, 0, 0, 0)),
63-
new Date(Date.UTC(2001, 0, 1, 0, 0, 0, 0)),
64-
])
65-
})
66-
67-
it('should support non-UTC dates', () => {
68-
const noUtcTimeScale = scaleTime().domain([
69-
new Date(2000, 0, 1, 0, 0, 0, 0),
70-
new Date(2001, 0, 1, 0, 0, 0, 0),
71-
])
72-
73-
expect(getScaleTicks(noUtcTimeScale)).toEqual([
74-
new Date(2000, 0, 1, 0, 0, 0, 0),
75-
new Date(2000, 1, 1, 0, 0, 0, 0),
76-
new Date(2000, 2, 1, 0, 0, 0, 0),
77-
new Date(2000, 3, 1, 0, 0, 0, 0),
78-
new Date(2000, 4, 1, 0, 0, 0, 0),
79-
new Date(2000, 5, 1, 0, 0, 0, 0),
80-
new Date(2000, 6, 1, 0, 0, 0, 0),
81-
new Date(2000, 7, 1, 0, 0, 0, 0),
82-
new Date(2000, 8, 1, 0, 0, 0, 0),
83-
new Date(2000, 9, 1, 0, 0, 0, 0),
84-
new Date(2000, 10, 1, 0, 0, 0, 0),
85-
new Date(2000, 11, 1, 0, 0, 0, 0),
86-
new Date(2001, 0, 1, 0, 0, 0, 0),
87-
])
88-
})
89-
90-
const intervals = [
91-
{
92-
interval: '5 years',
93-
domain: [
94-
new Date(Date.UTC(2000, 0, 1, 0, 0, 0, 0)),
95-
new Date(Date.UTC(2010, 0, 1, 0, 0, 0, 0)),
96-
],
97-
expect: [
98-
new Date(Date.UTC(2000, 0, 1, 0, 0, 0, 0)),
99-
new Date(Date.UTC(2005, 0, 1, 0, 0, 0, 0)),
100-
new Date(Date.UTC(2010, 0, 1, 0, 0, 0, 0)),
101-
],
102-
},
103-
{
104-
interval: 'year',
105-
domain: [
106-
new Date(Date.UTC(2001, 0, 1, 0, 0, 0, 0)),
107-
new Date(Date.UTC(2004, 0, 1, 0, 0, 0, 0)),
108-
],
109-
expect: [
110-
new Date(Date.UTC(2001, 0, 1, 0, 0, 0, 0)),
111-
new Date(Date.UTC(2002, 0, 1, 0, 0, 0, 0)),
112-
new Date(Date.UTC(2003, 0, 1, 0, 0, 0, 0)),
113-
new Date(Date.UTC(2004, 0, 1, 0, 0, 0, 0)),
114-
],
115-
},
116-
{
117-
interval: '3 months',
118-
domain: [
119-
new Date(Date.UTC(2009, 0, 1, 0, 0, 0, 0)),
120-
new Date(Date.UTC(2010, 0, 1, 0, 0, 0, 0)),
121-
],
122-
expect: [
123-
new Date(Date.UTC(2009, 0, 1, 0, 0, 0, 0)),
124-
new Date(Date.UTC(2009, 3, 1, 0, 0, 0, 0)),
125-
new Date(Date.UTC(2009, 6, 1, 0, 0, 0, 0)),
126-
new Date(Date.UTC(2009, 9, 1, 0, 0, 0, 0)),
127-
new Date(Date.UTC(2010, 0, 1, 0, 0, 0, 0)),
128-
],
129-
},
130-
{
131-
interval: '2 days',
132-
domain: [
133-
new Date(Date.UTC(2010, 0, 1, 0, 0, 0, 0)),
134-
new Date(Date.UTC(2010, 0, 7, 0, 0, 0, 0)),
135-
],
136-
expect: [
137-
new Date(Date.UTC(2010, 0, 1, 0, 0, 0, 0)),
138-
new Date(Date.UTC(2010, 0, 3, 0, 0, 0, 0)),
139-
new Date(Date.UTC(2010, 0, 5, 0, 0, 0, 0)),
140-
new Date(Date.UTC(2010, 0, 7, 0, 0, 0, 0)),
141-
],
142-
},
143-
{
144-
interval: 'wednesday',
145-
domain: [
146-
new Date(Date.UTC(2010, 0, 1, 0, 0, 0)),
147-
new Date(Date.UTC(2010, 1, 1, 0, 0, 0)),
148-
],
149-
expect: [
150-
new Date(Date.UTC(2010, 0, 6, 0, 0, 0)),
151-
new Date(Date.UTC(2010, 0, 13, 0, 0, 0)),
152-
new Date(Date.UTC(2010, 0, 20, 0, 0, 0)),
153-
new Date(Date.UTC(2010, 0, 27, 0, 0, 0)),
154-
],
155-
},
156-
{
157-
interval: '30 minutes',
158-
domain: [
159-
new Date(Date.UTC(2010, 0, 1, 6, 0, 0)),
160-
new Date(Date.UTC(2010, 0, 1, 9, 0, 0)),
161-
],
162-
expect: [
163-
new Date(Date.UTC(2010, 0, 1, 6, 0, 0)),
164-
new Date(Date.UTC(2010, 0, 1, 6, 30, 0)),
165-
new Date(Date.UTC(2010, 0, 1, 7, 0, 0)),
166-
new Date(Date.UTC(2010, 0, 1, 7, 30, 0)),
167-
new Date(Date.UTC(2010, 0, 1, 8, 0, 0)),
168-
new Date(Date.UTC(2010, 0, 1, 8, 30, 0)),
169-
new Date(Date.UTC(2010, 0, 1, 9, 0, 0)),
170-
],
171-
},
172-
]
173-
174-
intervals.forEach(interval => {
175-
it(`should support ${interval.interval} interval`, () => {
176-
const intervalTimeScale = scaleUtc().domain(interval.domain)
177-
178-
// set utc flag on our scale
179-
;(intervalTimeScale as any).useUTC = true
180-
181-
expect(getScaleTicks(intervalTimeScale, `every ${interval.interval}`)).toEqual(
182-
interval.expect
183-
)
184-
})
185-
})
186-
})
187-
})
1+
import { scaleLinear, scaleOrdinal, scalePoint, scaleBand } from 'd3-scale'
2+
// @ts-ignore
3+
import { computeCartesianTicks } from '../src/compute'
1884

1895
describe('computeCartesianTicks()', () => {
1906
const ordinalScale = scaleOrdinal([0, 10, 20, 30]).domain(['A', 'B', 'C', 'D'])

‎packages/scales/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
},
2929
"devDependencies": {
3030
"@types/d3-scale": "^3.2.2",
31+
"@types/d3-time": "^1.1.1",
3132
"@types/d3-time-format": "^3.0.0"
3233
},
3334
"publishConfig": {

‎packages/scales/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ export * from './pointScale'
66
export * from './timeScale'
77
export * from './timeHelpers'
88
export * from './bandScale'
9+
export * from './ticks'
910
export * from './types'

‎packages/scales/src/ticks.ts

+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import {
2+
CountableTimeInterval,
3+
timeMillisecond,
4+
utcMillisecond,
5+
timeSecond,
6+
utcSecond,
7+
timeMinute,
8+
utcMinute,
9+
timeHour,
10+
utcHour,
11+
timeWeek,
12+
utcWeek,
13+
timeSunday,
14+
utcSunday,
15+
timeMonday,
16+
utcMonday,
17+
timeTuesday,
18+
utcTuesday,
19+
timeWednesday,
20+
utcWednesday,
21+
timeThursday,
22+
utcThursday,
23+
timeFriday,
24+
utcFriday,
25+
timeSaturday,
26+
utcSaturday,
27+
timeMonth,
28+
utcMonth,
29+
timeYear,
30+
utcYear,
31+
timeInterval,
32+
} from 'd3-time'
33+
import { ScaleValue, TicksSpec, AnyScale, ScaleWithBandwidth } from './types'
34+
35+
export const centerScale = <Value>(scale: ScaleWithBandwidth) => {
36+
const bandwidth = scale.bandwidth()
37+
38+
if (bandwidth === 0) return scale
39+
40+
let offset = bandwidth / 2
41+
if (scale.round()) {
42+
offset = Math.round(offset)
43+
}
44+
45+
return <T extends Value>(d: T) => (scale(d) ?? 0) + offset
46+
}
47+
48+
const timeDay = timeInterval(
49+
date => date.setHours(0, 0, 0, 0),
50+
(date, step) => date.setDate(date.getDate() + step),
51+
(start, end) => (end.getTime() - start.getTime()) / 864e5,
52+
date => Math.floor(date.getTime() / 864e5)
53+
)
54+
55+
const utcDay = timeInterval(
56+
date => date.setUTCHours(0, 0, 0, 0),
57+
(date, step) => date.setUTCDate(date.getUTCDate() + step),
58+
(start, end) => (end.getTime() - start.getTime()) / 864e5,
59+
date => Math.floor(date.getTime() / 864e5)
60+
)
61+
62+
const timeByType: Record<string, [CountableTimeInterval, CountableTimeInterval]> = {
63+
millisecond: [timeMillisecond, utcMillisecond],
64+
second: [timeSecond, utcSecond],
65+
minute: [timeMinute, utcMinute],
66+
hour: [timeHour, utcHour],
67+
day: [timeDay, utcDay],
68+
week: [timeWeek, utcWeek],
69+
sunday: [timeSunday, utcSunday],
70+
monday: [timeMonday, utcMonday],
71+
tuesday: [timeTuesday, utcTuesday],
72+
wednesday: [timeWednesday, utcWednesday],
73+
thursday: [timeThursday, utcThursday],
74+
friday: [timeFriday, utcFriday],
75+
saturday: [timeSaturday, utcSaturday],
76+
month: [timeMonth, utcMonth],
77+
year: [timeYear, utcYear],
78+
}
79+
80+
const timeTypes = Object.keys(timeByType)
81+
const timeIntervalRegexp = new RegExp(`^every\\s*(\\d+)?\\s*(${timeTypes.join('|')})s?$`, 'i')
82+
83+
const isInteger = (value: unknown): value is number =>
84+
typeof value === 'number' && isFinite(value) && Math.floor(value) === value
85+
86+
export const getScaleTicks = <Value extends ScaleValue>(
87+
scale: AnyScale,
88+
spec?: TicksSpec<Value>
89+
) => {
90+
// specific values
91+
if (Array.isArray(spec)) {
92+
return spec
93+
}
94+
95+
if (typeof spec === 'string' && 'useUTC' in scale) {
96+
// time interval
97+
const matches = spec.match(timeIntervalRegexp)
98+
99+
if (matches) {
100+
const [, amount, type] = matches
101+
// UTC is used as it's more predictable
102+
// however local time could be used too
103+
// let's see how it fits users' requirements
104+
const timeType = timeByType[type][scale.useUTC ? 1 : 0]
105+
106+
if (type === 'day') {
107+
const [start, originalStop] = scale.domain()
108+
const stop = new Date(originalStop)
109+
110+
// Set range to include last day in the domain since `interval.range` function is exclusive stop
111+
stop.setDate(stop.getDate() + 1)
112+
113+
return timeType.every(Number(amount ?? 1))?.range(start, stop) ?? []
114+
}
115+
116+
if (amount === undefined) {
117+
return scale.ticks(timeType)
118+
}
119+
120+
const interval = timeType.every(Number(amount))
121+
122+
if (interval) {
123+
return scale.ticks(interval)
124+
}
125+
}
126+
127+
throw new Error(`Invalid tickValues: ${spec}`)
128+
}
129+
130+
// continuous scales
131+
if ('ticks' in scale) {
132+
// default behaviour
133+
if (spec === undefined) {
134+
return scale.ticks()
135+
}
136+
137+
// specific tick count
138+
if (isInteger(spec)) {
139+
return scale.ticks(spec)
140+
}
141+
}
142+
143+
// non linear scale default
144+
return scale.domain()
145+
}

‎packages/scales/src/types.ts

+16
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ export interface ScaleTime<Input> extends D3ScaleTime<Input, number> {
121121
useUTC: boolean
122122
}
123123

124+
export type AnyScale = Scale<any, any>
125+
126+
export type ScaleWithBandwidth = ScaleBand<any> | ScalePoint<any>
127+
124128
export type Series<XValue extends ScaleValue, YValue extends ScaleValue> = {
125129
data: {
126130
data: {
@@ -144,3 +148,15 @@ export type ComputedSerieAxis<Value extends ScaleValue> = {
144148
max: Value
145149
maxStacked?: Value
146150
}
151+
152+
export type TicksSpec<Value extends ScaleValue> =
153+
// exact number of ticks, please note that
154+
// depending on the current range of values,
155+
// you might not get this exact count
156+
| number
157+
// string is used for Date based scales,
158+
// it can express a time interval,
159+
// for example: every 2 weeks
160+
| string
161+
// override scale ticks with custom explicit values
162+
| Value[]

‎packages/scales/tests/ticks.test.ts

+186
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { scaleLinear, scaleTime, scaleUtc } from 'd3-scale'
2+
// @ts-ignore
3+
import { getScaleTicks } from '../src'
4+
5+
describe('linear scale', () => {
6+
const linearScale = scaleLinear().domain([0, 500])
7+
8+
it('should return default ticks', () => {
9+
expect(getScaleTicks(linearScale)).toEqual([
10+
0,
11+
50,
12+
100,
13+
150,
14+
200,
15+
250,
16+
300,
17+
350,
18+
400,
19+
450,
20+
500,
21+
])
22+
})
23+
24+
it('should support using a count', () => {
25+
expect(getScaleTicks(linearScale, 6)).toEqual([0, 100, 200, 300, 400, 500])
26+
})
27+
28+
it('should support using specific values', () => {
29+
expect(getScaleTicks(linearScale, [0, 200, 400])).toEqual([0, 200, 400])
30+
})
31+
})
32+
33+
describe('time scale', () => {
34+
const timeScale = scaleUtc().domain([
35+
new Date(Date.UTC(2000, 0, 1, 0, 0, 0, 0)),
36+
new Date(Date.UTC(2001, 0, 1, 0, 0, 0, 0)),
37+
])
38+
39+
it('should return default ticks', () => {
40+
expect(getScaleTicks(timeScale)).toEqual([
41+
new Date(Date.UTC(2000, 0, 1, 0, 0, 0, 0)),
42+
new Date(Date.UTC(2000, 1, 1, 0, 0, 0, 0)),
43+
new Date(Date.UTC(2000, 2, 1, 0, 0, 0, 0)),
44+
new Date(Date.UTC(2000, 3, 1, 0, 0, 0, 0)),
45+
new Date(Date.UTC(2000, 4, 1, 0, 0, 0, 0)),
46+
new Date(Date.UTC(2000, 5, 1, 0, 0, 0, 0)),
47+
new Date(Date.UTC(2000, 6, 1, 0, 0, 0, 0)),
48+
new Date(Date.UTC(2000, 7, 1, 0, 0, 0, 0)),
49+
new Date(Date.UTC(2000, 8, 1, 0, 0, 0, 0)),
50+
new Date(Date.UTC(2000, 9, 1, 0, 0, 0, 0)),
51+
new Date(Date.UTC(2000, 10, 1, 0, 0, 0, 0)),
52+
new Date(Date.UTC(2000, 11, 1, 0, 0, 0, 0)),
53+
new Date(Date.UTC(2001, 0, 1, 0, 0, 0, 0)),
54+
])
55+
})
56+
57+
it('should support using a count', () => {
58+
expect(getScaleTicks(timeScale, 4)).toEqual([
59+
new Date(Date.UTC(2000, 0, 1, 0, 0, 0, 0)),
60+
new Date(Date.UTC(2000, 3, 1, 0, 0, 0, 0)),
61+
new Date(Date.UTC(2000, 6, 1, 0, 0, 0, 0)),
62+
new Date(Date.UTC(2000, 9, 1, 0, 0, 0, 0)),
63+
new Date(Date.UTC(2001, 0, 1, 0, 0, 0, 0)),
64+
])
65+
})
66+
67+
it('should support non-UTC dates', () => {
68+
const noUtcTimeScale = scaleTime().domain([
69+
new Date(2000, 0, 1, 0, 0, 0, 0),
70+
new Date(2001, 0, 1, 0, 0, 0, 0),
71+
])
72+
73+
expect(getScaleTicks(noUtcTimeScale)).toEqual([
74+
new Date(2000, 0, 1, 0, 0, 0, 0),
75+
new Date(2000, 1, 1, 0, 0, 0, 0),
76+
new Date(2000, 2, 1, 0, 0, 0, 0),
77+
new Date(2000, 3, 1, 0, 0, 0, 0),
78+
new Date(2000, 4, 1, 0, 0, 0, 0),
79+
new Date(2000, 5, 1, 0, 0, 0, 0),
80+
new Date(2000, 6, 1, 0, 0, 0, 0),
81+
new Date(2000, 7, 1, 0, 0, 0, 0),
82+
new Date(2000, 8, 1, 0, 0, 0, 0),
83+
new Date(2000, 9, 1, 0, 0, 0, 0),
84+
new Date(2000, 10, 1, 0, 0, 0, 0),
85+
new Date(2000, 11, 1, 0, 0, 0, 0),
86+
new Date(2001, 0, 1, 0, 0, 0, 0),
87+
])
88+
})
89+
90+
const intervals = [
91+
{
92+
interval: '5 years',
93+
domain: [
94+
new Date(Date.UTC(2000, 0, 1, 0, 0, 0, 0)),
95+
new Date(Date.UTC(2010, 0, 1, 0, 0, 0, 0)),
96+
],
97+
expect: [
98+
new Date(Date.UTC(2000, 0, 1, 0, 0, 0, 0)),
99+
new Date(Date.UTC(2005, 0, 1, 0, 0, 0, 0)),
100+
new Date(Date.UTC(2010, 0, 1, 0, 0, 0, 0)),
101+
],
102+
},
103+
{
104+
interval: 'year',
105+
domain: [
106+
new Date(Date.UTC(2001, 0, 1, 0, 0, 0, 0)),
107+
new Date(Date.UTC(2004, 0, 1, 0, 0, 0, 0)),
108+
],
109+
expect: [
110+
new Date(Date.UTC(2001, 0, 1, 0, 0, 0, 0)),
111+
new Date(Date.UTC(2002, 0, 1, 0, 0, 0, 0)),
112+
new Date(Date.UTC(2003, 0, 1, 0, 0, 0, 0)),
113+
new Date(Date.UTC(2004, 0, 1, 0, 0, 0, 0)),
114+
],
115+
},
116+
{
117+
interval: '3 months',
118+
domain: [
119+
new Date(Date.UTC(2009, 0, 1, 0, 0, 0, 0)),
120+
new Date(Date.UTC(2010, 0, 1, 0, 0, 0, 0)),
121+
],
122+
expect: [
123+
new Date(Date.UTC(2009, 0, 1, 0, 0, 0, 0)),
124+
new Date(Date.UTC(2009, 3, 1, 0, 0, 0, 0)),
125+
new Date(Date.UTC(2009, 6, 1, 0, 0, 0, 0)),
126+
new Date(Date.UTC(2009, 9, 1, 0, 0, 0, 0)),
127+
new Date(Date.UTC(2010, 0, 1, 0, 0, 0, 0)),
128+
],
129+
},
130+
{
131+
interval: '2 days',
132+
domain: [
133+
new Date(Date.UTC(2010, 0, 1, 0, 0, 0, 0)),
134+
new Date(Date.UTC(2010, 0, 7, 0, 0, 0, 0)),
135+
],
136+
expect: [
137+
new Date(Date.UTC(2010, 0, 1, 0, 0, 0, 0)),
138+
new Date(Date.UTC(2010, 0, 3, 0, 0, 0, 0)),
139+
new Date(Date.UTC(2010, 0, 5, 0, 0, 0, 0)),
140+
new Date(Date.UTC(2010, 0, 7, 0, 0, 0, 0)),
141+
],
142+
},
143+
{
144+
interval: 'wednesday',
145+
domain: [
146+
new Date(Date.UTC(2010, 0, 1, 0, 0, 0)),
147+
new Date(Date.UTC(2010, 1, 1, 0, 0, 0)),
148+
],
149+
expect: [
150+
new Date(Date.UTC(2010, 0, 6, 0, 0, 0)),
151+
new Date(Date.UTC(2010, 0, 13, 0, 0, 0)),
152+
new Date(Date.UTC(2010, 0, 20, 0, 0, 0)),
153+
new Date(Date.UTC(2010, 0, 27, 0, 0, 0)),
154+
],
155+
},
156+
{
157+
interval: '30 minutes',
158+
domain: [
159+
new Date(Date.UTC(2010, 0, 1, 6, 0, 0)),
160+
new Date(Date.UTC(2010, 0, 1, 9, 0, 0)),
161+
],
162+
expect: [
163+
new Date(Date.UTC(2010, 0, 1, 6, 0, 0)),
164+
new Date(Date.UTC(2010, 0, 1, 6, 30, 0)),
165+
new Date(Date.UTC(2010, 0, 1, 7, 0, 0)),
166+
new Date(Date.UTC(2010, 0, 1, 7, 30, 0)),
167+
new Date(Date.UTC(2010, 0, 1, 8, 0, 0)),
168+
new Date(Date.UTC(2010, 0, 1, 8, 30, 0)),
169+
new Date(Date.UTC(2010, 0, 1, 9, 0, 0)),
170+
],
171+
},
172+
]
173+
174+
intervals.forEach(interval => {
175+
it(`should support ${interval.interval} interval`, () => {
176+
const intervalTimeScale = scaleUtc().domain(interval.domain)
177+
178+
// set utc flag on our scale
179+
;(intervalTimeScale as any).useUTC = true
180+
181+
expect(getScaleTicks(intervalTimeScale, `every ${interval.interval}`)).toEqual(
182+
interval.expect
183+
)
184+
})
185+
})
186+
})

‎tsconfig.monorepo.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
{ "path": "./packages/annotations" },
99
{ "path": "./packages/scales" },
1010
{ "path": "./packages/axes" },
11-
{ "path": "./packages/polar-axes" },
1211
{ "path": "./packages/colors" },
1312
{ "path": "./packages/legends" },
1413
{ "path": "./packages/tooltip" },
1514
{ "path": "./packages/arcs" },
15+
{ "path": "./packages/polar-axes" },
1616
{ "path": "./packages/voronoi" },
1717

1818
// Utility package

0 commit comments

Comments
 (0)
Please sign in to comment.