diff --git a/giraffe/README.md b/giraffe/README.md index 194d5234..15f9848b 100644 --- a/giraffe/README.md +++ b/giraffe/README.md @@ -258,9 +258,21 @@ When using the comma separated values (CSV) from the Flux query as the `fluxResp - **axisOpacity**: _number. Optional. Recommendation: do not include. Defaults to 1 when excluded._ A value between 0 and 1 inclusive for the [_CanvasRenderingContext2D globalAlpha_](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalAlpha) of the axes and the border around the graph. Excludes the inner horizontal and vertical rule lines. -- **xTicks**: _array[number, ...]. Optional._ An array of values representing tick marks on the x-axis. Actual data values and axis scaling may cause Plot to not render all of the given ticks, or Plot rendering may extend beyond all of the rendered ticks. When excluded, Giraffe attempts to use as many ticks as possible on the x-axis while keeping reasonable spacing between them. +- **xTicks**: _array[number, ...]. Optional._ An array of values representing tick marks on the x-axis. Actual data values and axis scaling may cause Plot to not render all of the given ticks, or Plot rendering may extend beyond all of the rendered ticks. When this option is included, **xTotalTicks**, **xTickStep**, **xTickStart** are ignored. -- **yTicks**: _array[number, ...]. Optional._ An array of values representing tick marks on the y-axis. Actual data values and axis scaling may cause Plot to not render all of the given ticks, or Plot rendering may extend beyond all of the rendered ticks. When excluded, Giraffe attempts to use as many ticks as possible on the y-axis while keeping reasonable spacing between them. +- **xTotalTicks**: _number. Optional. **Ignored when xTicks is specified**._ A number representing the maximum possible number of ticks to generate on the x-axis. Uses the **xTickStep** as the tick interval if also included. Otherwise the tick interval is taken from dividing the length of the rendered domain by this number. The actual number of rendered ticks may be less than this number due to the size of the tick interval. + +- **xTickStep**: _number. Optional. **Ignored when xTicks is specified**._ A number representing the tick interval for the x-axis. May be negative. + +- **xTickStart**: _number. Optional. **Ignored when xTicks is specified**._ A number representing a value less than or equal to the first tick on the x-axis. This number will determine the placement of all subsequent ticks. It and any subsequent ticks will be rendered only if they fall within the domain. This number _is_ the value of the first tick when it is in the domain, and at least one of **xTickStep** or **xTotalTicks** is included. + +- **yTicks**: _array[number, ...]. Optional._ An array of values representing tick marks on the y-axis. Actual data values and axis scaling may cause Plot to not render all of the given ticks, or Plot rendering may extend beyond all of the rendered ticks. When this option is included, **yTotalTicks**, **yTickStep**, **yTickStart** are ignored. + +- **yTotalTicks**: _number. Optional. **Ignored when yTicks is specified**._ A number representing the maximum possible number of ticks to generate on the y-axis. Uses the **yTickStep** as the tick interval if also included. Otherwise the tick interval is taken from dividing the length of the rendered domain by this number. The actual number of rendered ticks may be less than this number due to the size of the tick interval. + +- **yTickStep**: _number. Optional. **Ignored when yTicks is specified**._ A number representing the tick interval for the y-axis. May be negative. + +- **yTickStart**: _number. Optional. **Ignored when yTicks is specified**._ A number representing a value less than or equal to the first tick on the y-axis. This number will determine the placement of all subsequent ticks. It and any subsequent ticks will be rendered only if they fall within the domain. This number _is_ the value of the first tick when it is in the domain, and at least one of **yTickStep** or **yTotalTicks** is included. - **tickFont**: _string. Optional._ The [_CSS font_](https://developer.mozilla.org/en-US/docs/Web/CSS/font) value for the styling of the tick labels and axis labels. diff --git a/giraffe/src/types/index.ts b/giraffe/src/types/index.ts index 7392bd0a..5fb31590 100644 --- a/giraffe/src/types/index.ts +++ b/giraffe/src/types/index.ts @@ -17,15 +17,26 @@ export interface Config { axisColor?: string axisOpacity?: number - // Ticks on the axes can be specified, or else they are calculated, - // as well as the font, color, and the unit labels for each tick + // Tick placement on the axes can be specified otherwise they are calculated, + // - specified for an entire axis, or + // - specified by a step interval per tick and/or a total number of ticks xTicks?: number[] + xTickStart?: number + xTickStep?: number + xTotalTicks?: number yTicks?: Array + yTickStart?: number + yTickStep?: number + yTotalTicks?: number + + // Ticks can have font, color, and be formatted for precision and labeling tickFont?: string tickFontColor?: string valueFormatters?: { [colKey: string]: Formatter } + + // The labels on the axes xAxisLabel?: string yAxisLabel?: string diff --git a/giraffe/src/utils/PlotEnv.ts b/giraffe/src/utils/PlotEnv.ts index baa0f589..041c03f8 100644 --- a/giraffe/src/utils/PlotEnv.ts +++ b/giraffe/src/utils/PlotEnv.ts @@ -65,11 +65,23 @@ export class PlotEnv { } = this const getMarginsMemoized = this.fns.get('margins', getMargins) + const sampleYTicks = yTicks.length + ? yTicks + : getVerticalTicks( + this.yDomain, + this.config.height, + this.config.tickFont, + this.yTickFormatter, + null, + null, + null + ) + return getMarginsMemoized( this.config.showAxes, xAxisLabel, yAxisLabel, - yTicks, + sampleYTicks, this.yTickFormatter, tickFont ) @@ -100,7 +112,10 @@ export class PlotEnv { this.xDomain, this.config.width, this.config.tickFont, - this.xTickFormatter + this.xTickFormatter, + this.config.xTotalTicks, + this.config.xTickStart, + this.config.xTickStep ) } @@ -122,7 +137,10 @@ export class PlotEnv { this.yDomain, this.config.height, this.config.tickFont, - this.yTickFormatter + this.yTickFormatter, + this.config.yTotalTicks, + this.config.yTickStart, + this.config.yTickStep ) } diff --git a/giraffe/src/utils/getTextMetrics.ts b/giraffe/src/utils/getTextMetrics.ts index 45ed30d8..2759295f 100644 --- a/giraffe/src/utils/getTextMetrics.ts +++ b/giraffe/src/utils/getTextMetrics.ts @@ -17,7 +17,12 @@ export const getTextMetrics = (font: string, text: string): TextMetrics => { document.body.appendChild(div) - span.innerText = text + // Text with the same number of characters: + // when it includes a dash (negative number), it tends to be skinnier than + // a positive number because a dash is not as wide as a single digit (most of the time). + // Add padding when text has a dash by making dashes twice as wide. + span.innerText = + typeof text === 'string' && text.includes('-') ? `-${text}` : text const metrics = { width: span.offsetWidth, diff --git a/giraffe/src/utils/getTicks.test.ts b/giraffe/src/utils/getTicks.test.ts index 816d5e7c..181197ab 100644 --- a/giraffe/src/utils/getTicks.test.ts +++ b/giraffe/src/utils/getTicks.test.ts @@ -1,13 +1,293 @@ import {FormatterType} from '../types' import {TIME, VALUE} from '../constants/columnKeys' -import {getTicks, getVerticalTicks, getHorizontalTicks} from './getTicks' +import { + calculateTicks, + generateTicks, + getVerticalTicks, + getHorizontalTicks, +} from './getTicks' jest.mock('./getTextMetrics') describe('utils/getTicks', () => { const font = '10px sans-serif' - describe('getTicks', () => { + describe('generateTicks', () => { + describe('value ticks', () => { + const valueDomain = [1, 300] + const timeDomain = [1603333737559, 1603334037559] + + it('should generate no ticks when total ticks, tick start, and tick step are not finite numbers', () => { + expect(generateTicks(valueDomain, VALUE, NaN, NaN, NaN).length).toEqual( + 0 + ) + expect( + generateTicks(valueDomain, VALUE, null, null, null).length + ).toEqual(0) + expect( + generateTicks(valueDomain, VALUE, Infinity, Infinity, Infinity).length + ).toEqual(0) + expect( + generateTicks(valueDomain, VALUE, -Infinity, -Infinity, -Infinity) + .length + ).toEqual(0) + }) + + it('should generate exactly the total ticks when given the total ticks, but no tick start and no tick step', () => { + expect( + generateTicks(valueDomain, VALUE, 10, null, null).length + ).toEqual(10) + + expect(generateTicks(timeDomain, TIME, 10, null, null).length).toEqual( + 10 + ) + }) + + it('should generate no ticks when given only a tick start, but no total ticks and no tick step', () => { + const [start, end] = valueDomain + let tickStart = (end - start) / 2 + expect( + generateTicks(valueDomain, VALUE, null, tickStart, null).length + ).toEqual(0) + + tickStart = start + expect( + generateTicks(valueDomain, VALUE, null, tickStart, null).length + ).toEqual(0) + + tickStart = start + 1 + expect( + generateTicks(valueDomain, VALUE, null, tickStart, null).length + ).toEqual(0) + + tickStart = end + expect( + generateTicks(valueDomain, VALUE, null, tickStart, null).length + ).toEqual(0) + + tickStart = end - 1 + expect( + generateTicks(valueDomain, VALUE, null, tickStart, null).length + ).toEqual(0) + }) + + it('should generate ticks accordingly when given a tick step, but no total ticks and no tick start', () => { + const [start, end] = valueDomain + let tickStep = (end - start) / 10 + let result = generateTicks(valueDomain, VALUE, null, null, tickStep) + expect(result.length).toBeGreaterThan(0) + + tickStep = (end - start) / 11 + result = generateTicks(valueDomain, VALUE, null, null, tickStep) + expect(result.length).toBeGreaterThan(0) + + tickStep = end - start + result = generateTicks(valueDomain, VALUE, null, null, tickStep) + expect(result.length).toBeGreaterThan(0) + + tickStep = end - start + 1 + expect( + generateTicks(valueDomain, VALUE, null, null, tickStep).length + ).toEqual(0) + + expect(() => { + tickStep = 0 + expect( + generateTicks(valueDomain, VALUE, null, null, tickStep).length + ).toEqual(0) + }).not.toThrow() + + tickStep = -10 + expect( + generateTicks(valueDomain, VALUE, null, null, tickStep).length + ).toEqual(0) + + tickStep = -1000 + expect( + generateTicks(timeDomain, TIME, null, null, tickStep).length + ).toEqual(0) + }) + + it('should generate ticks when given the total ticks, a tick start, but no tick step', () => { + const totalTicks = 10 + let result = generateTicks( + valueDomain, + VALUE, + totalTicks, + valueDomain[0], + null + ) + expect(result.length).toBeGreaterThan(0) + expect(result.length).toBeLessThanOrEqual(totalTicks) + + result = generateTicks( + timeDomain, + TIME, + totalTicks, + timeDomain[0], + null + ) + expect(result.length).toBeGreaterThan(0) + expect(result.length).toBeLessThanOrEqual(totalTicks) + + result = generateTicks( + valueDomain, + VALUE, + totalTicks, + valueDomain[1] - 1, + null + ) + expect(result.length).toBeGreaterThan(0) + expect(result.length).toBeLessThanOrEqual(totalTicks) + }) + + it('should generate ticks accordingly when given total ticks, a tick step, but no tick start', () => { + const totalTicks = 10 + const [start, end] = valueDomain + const [timeStart, timeEnd] = timeDomain + let tickStep = (end - start) / totalTicks + let result = generateTicks( + valueDomain, + VALUE, + totalTicks, + null, + tickStep + ) + expect(result.length).toBeGreaterThan(0) + expect(result.length).toBeLessThanOrEqual(totalTicks) + + tickStep = (timeEnd - timeStart) / totalTicks + result = generateTicks(timeDomain, TIME, totalTicks, null, tickStep) + expect(result.length).toBeGreaterThan(0) + expect(result.length).toBeLessThanOrEqual(totalTicks) + + tickStep = (end - start) / (totalTicks + 1) + result = generateTicks(valueDomain, VALUE, totalTicks, null, tickStep) + expect(result.length).toBeGreaterThan(0) + expect(result.length).toBeLessThanOrEqual(totalTicks) + + tickStep = end - start + result = generateTicks(valueDomain, VALUE, totalTicks, null, tickStep) + expect(result.length).toBeGreaterThan(0) + expect(result.length).toBeLessThanOrEqual(totalTicks) + + tickStep = ((end - start) / (totalTicks + 1)) * -1 + expect( + generateTicks(valueDomain, VALUE, totalTicks, null, tickStep).length + ).toEqual(0) + + tickStep = ((timeEnd - timeStart) / (totalTicks + 1)) * -1 + expect( + generateTicks(timeDomain, TIME, totalTicks, null, tickStep).length + ).toEqual(0) + + tickStep = end - start + 1 + expect( + generateTicks(valueDomain, VALUE, totalTicks, null, tickStep).length + ).toEqual(0) + }) + + it('should generate ticks accordingly when given total ticks, tick start, and tick step', () => { + const [start, end] = valueDomain + const [timeStart, timeEnd] = timeDomain + let totalTicks = 10 + let tickStart = start + let tickStep = (end - start) / totalTicks + let result = generateTicks( + valueDomain, + VALUE, + totalTicks, + tickStart, + tickStep + ) + expect(result.length).toBeGreaterThan(0) + expect(result.length).toBeLessThanOrEqual(totalTicks) + + tickStart = timeStart + tickStep = (timeEnd - timeStart) / totalTicks + result = generateTicks( + timeDomain, + TIME, + totalTicks, + tickStart, + tickStep + ) + expect(result.length).toBeGreaterThan(0) + expect(result.length).toBeLessThanOrEqual(totalTicks) + + totalTicks = 10 + tickStart = end + tickStep = ((end - start) / (totalTicks + 1)) * -1 + result = generateTicks( + valueDomain, + VALUE, + totalTicks, + tickStart, + tickStep + ) + expect(result.length).toBeGreaterThan(0) + expect(result.length).toBeLessThanOrEqual(totalTicks) + + tickStart = timeEnd + tickStep = ((timeEnd - timeStart) / (totalTicks + 1)) * -1 + result = generateTicks( + timeDomain, + TIME, + totalTicks, + tickStart, + tickStep + ) + expect(result.length).toBeGreaterThan(0) + expect(result.length).toBeLessThanOrEqual(totalTicks) + + expect(() => { + tickStep = 0 + expect( + generateTicks(valueDomain, VALUE, totalTicks, tickStart, tickStep) + .length + ).toEqual(0) + }).not.toThrow() + + totalTicks = 1 + tickStart = start + tickStep = 0.01 + result = generateTicks( + valueDomain, + VALUE, + totalTicks, + tickStart, + tickStep + ) + expect(result.length).toEqual(totalTicks) + + totalTicks = 1 + tickStart = timeDomain[0] + tickStep = 1 + result = generateTicks( + timeDomain, + TIME, + totalTicks, + tickStart, + tickStep + ) + expect(result.length).toEqual(totalTicks) + + totalTicks = 15 + tickStart = start - 1 + tickStep = end - start + 1 + result = generateTicks( + valueDomain, + VALUE, + totalTicks, + tickStart, + tickStep + ) + expect(result.length).toEqual(0) + }) + }) + }) + + describe('calculateTicks', () => { describe('time ticks', () => { const columnKey = TIME @@ -16,9 +296,9 @@ describe('utils/getTicks', () => { const rangeLength = 1200 const tickSize = 110 const result = [1586383200000, 1586383500000, 1586383800000] - expect(getTicks(timeDomain, rangeLength, tickSize, columnKey)).toEqual( - result - ) + expect( + calculateTicks(timeDomain, rangeLength, tickSize, columnKey) + ).toEqual(result) }) it('should handle 4 to 10 time ticks', () => { @@ -31,9 +311,9 @@ describe('utils/getTicks', () => { 1586384400000, 1586384700000, ] - expect(getTicks(timeDomain, rangeLength, tickSize, columnKey)).toEqual( - result - ) + expect( + calculateTicks(timeDomain, rangeLength, tickSize, columnKey) + ).toEqual(result) timeDomain = [1586384617834, 1586385140120] rangeLength = 1042 @@ -49,9 +329,9 @@ describe('utils/getTicks', () => { 1586385060000, 1586385120000, ] - expect(getTicks(timeDomain, rangeLength, tickSize, columnKey)).toEqual( - result - ) + expect( + calculateTicks(timeDomain, rangeLength, tickSize, columnKey) + ).toEqual(result) }) it('should handle more than 10 ticks', () => { @@ -79,33 +359,33 @@ describe('utils/getTicks', () => { 1586385720000, 1586385780000, ] - expect(getTicks(timeDomain, rangeLength, tickSize, columnKey)).toEqual( - result - ) + expect( + calculateTicks(timeDomain, rangeLength, tickSize, columnKey) + ).toEqual(result) }) }) describe('value ticks', () => { const columnKey = VALUE - it('should handle fewer than 4 time ticks', () => { + it('should handle fewer than 4 ticks', () => { const valueDomain = [1, 300] const rangeLength = 1116 const tickSize = 172 const result = [100, 200, 300] - expect(getTicks(valueDomain, rangeLength, tickSize, columnKey)).toEqual( - result - ) + expect( + calculateTicks(valueDomain, rangeLength, tickSize, columnKey) + ).toEqual(result) }) - it('should handle 4 to 10 time ticks', () => { + it('should handle 4 to 10 ticks', () => { const valueDomain = [1, 300] const rangeLength = 1116 const tickSize = 86 const result = [50, 100, 150, 200, 250, 300] - expect(getTicks(valueDomain, rangeLength, tickSize, columnKey)).toEqual( - result - ) + expect( + calculateTicks(valueDomain, rangeLength, tickSize, columnKey) + ).toEqual(result) }) it('should handle more than 10 ticks', () => { @@ -129,9 +409,9 @@ describe('utils/getTicks', () => { 280, 300, ] - expect(getTicks(valueDomain, rangeLength, tickSize, columnKey)).toEqual( - result - ) + expect( + calculateTicks(valueDomain, rangeLength, tickSize, columnKey) + ).toEqual(result) }) }) }) diff --git a/giraffe/src/utils/getTicks.ts b/giraffe/src/utils/getTicks.ts index 2a966283..b051f158 100644 --- a/giraffe/src/utils/getTicks.ts +++ b/giraffe/src/utils/getTicks.ts @@ -11,6 +11,7 @@ import {TIME, VALUE} from '../constants/columnKeys' // Utils import {getTextMetrics} from './getTextMetrics' +import {isFiniteNumber} from './isFiniteNumber' /* Minimum spacing defined as: @@ -77,7 +78,7 @@ const getOptimalTicks = ( } /* - Same as getOptimalTicks but for Date objects + Similar to getOptimalTicks but for Date objects */ const getOptimalTimeTicks = ( [d0, d1]: number[], @@ -101,7 +102,8 @@ const getOptimalTimeTicks = ( return optimalTicks } -export const getTicks = ( +// calculateTicks adheres to spacing requirements as described in helper functions above +export const calculateTicks = ( domain: number[], rangeLength: number, tickSize: number, @@ -112,20 +114,93 @@ export const getTicks = ( : getOptimalTicks(domain, rangeLength, tickSize) } -const getMemoizedVerticalTicks = memoizeOne( - ( - domain: number[], - rangeLength: number, - tickSize: number, - columnKey: string - ) => getTicks(domain, rangeLength, tickSize, columnKey) -) +/* + generateTicks gives control to the user over placement, number, and interval of ticks + defers to the user's judgement on spacing (tick labels may overlap) +*/ +export const generateTicks = ( + domain: number[], + columnKey: string, + totalTicks: number, + tickStart: number, + tickStep: number +): number[] => { + const generatedTicks = [] + const [start, end] = domain + const stepStart = isFiniteNumber(tickStart) ? tickStart : start + const step = isFiniteNumber(tickStep) + ? tickStep + : (end - stepStart) / (totalTicks + 1) + const tickCountLimit = isFiniteNumber(totalTicks) ? totalTicks : Infinity + + let counter = isFiniteNumber(tickStart) ? 0 : 1 + let generatedTick = stepStart + step * counter + + if ( + tickStep !== 0 && + (isFiniteNumber(totalTicks) || isFiniteNumber(tickStep)) + ) { + while ( + generatedTick >= start && + generatedTick <= end && + generatedTicks.length < tickCountLimit + ) { + if (columnKey === TIME) { + generatedTicks.push(new Date(generatedTick)) + } else { + generatedTicks.push(generatedTick) + } + counter += 1 + generatedTick = stepStart + step * counter + } + } + return generatedTicks +} + +/* + ticks can be either + - generated: user's defined parameters and judgmenet for spacing + - calculated: Giraffe's determination and spacing requirements +*/ +const getTicks = ( + domain: number[], + rangeLength: number, + tickSize: number, + columnKey: string, + totalTicks?: number, + tickStart?: number, + tickStep?: number +) => { + const [start = 0, end = 0] = domain + if (isFiniteNumber(totalTicks) || isFiniteNumber(tickStep)) { + return generateTicks(domain, columnKey, totalTicks, tickStart, tickStep) + } + if (isFiniteNumber(tickStart)) { + const specifiedTickStart = Math.min(Math.max(tickStart, start), end) + return calculateTicks( + [specifiedTickStart, end], + rangeLength, + tickSize, + columnKey + ) + } + return calculateTicks(domain, rangeLength, tickSize, columnKey) +} + +/* + for performance memoize both the original arguments and calculated arguments + keep this separate from a memoized horizontal version +*/ +const getMemoizedVerticalTicks = memoizeOne(getTicks) export const getVerticalTicks = memoizeOne( ( domain: number[], rangeLength: number, tickFont: string, - formatter: Formatter + formatter: Formatter, + totalTicks?: number, + tickStart?: number, + tickStep?: number ): number[] => { const sampleTick = formatter(domain[1]) const tickTextMetrics = getTextMetrics(tickFont, sampleTick) @@ -138,24 +213,32 @@ export const getVerticalTicks = memoizeOne( const columnKey = formatter._GIRAFFE_FORMATTER_TYPE === FormatterType.Time ? TIME : VALUE - return getMemoizedVerticalTicks(domain, rangeLength, tickHeight, columnKey) + return getMemoizedVerticalTicks( + domain, + rangeLength, + tickHeight, + columnKey, + totalTicks, + tickStart, + tickStep + ) } ) -const getMemoizedHorizontalTicks = memoizeOne( - ( - domain: number[], - rangeLength: number, - tickSize: number, - columnKey: string - ) => getTicks(domain, rangeLength, tickSize, columnKey) -) +/* + for performance memoize both the original arguments and calculated arguments + keep this separate from a memoized vertical version +*/ +const getMemoizedHorizontalTicks = memoizeOne(getTicks) export const getHorizontalTicks = memoizeOne( ( domain: number[], rangeLength: number, tickFont: string, - formatter: Formatter + formatter: Formatter, + totalTicks?: number, + tickStart?: number, + tickStep?: number ): number[] => { const sampleTick = formatter(domain[1]) const tickTextMetrics = getTextMetrics(tickFont, sampleTick) @@ -167,7 +250,10 @@ export const getHorizontalTicks = memoizeOne( domain, rangeLength, tickTextMetrics.width, - columnKey + columnKey, + totalTicks, + tickStart, + tickStep ) } ) diff --git a/giraffe/src/utils/isFiniteNumber.test.ts b/giraffe/src/utils/isFiniteNumber.test.ts new file mode 100644 index 00000000..04c8cd36 --- /dev/null +++ b/giraffe/src/utils/isFiniteNumber.test.ts @@ -0,0 +1,45 @@ +import {isFiniteNumber} from './isFiniteNumber' + +describe('isFiniteNumber', () => { + it('the value created from the Number constructor is a finite number', () => { + const value = new Number() + + expect(typeof value === 'object').toEqual(true) + expect(isFiniteNumber(value)).toEqual(true) + }) + + it('the value created from the Number function is a finite number', () => { + const value = Number() + + expect(typeof value !== 'object').toEqual(true) + expect(isFiniteNumber(value)).toEqual(true) + }) + + it('a literal number and constants are finite numbers', () => { + expect(isFiniteNumber(1)).toEqual(true) + expect(isFiniteNumber(123456789)).toEqual(true) + expect(isFiniteNumber(0)).toEqual(true) + expect(isFiniteNumber(-4567.89)).toEqual(true) + expect(isFiniteNumber(Math.PI)).toEqual(true) + }) + + it('Infinity and negative Infinity are not finite numbers', () => { + expect(isFiniteNumber(Infinity)).toEqual(false) + expect(isFiniteNumber(-Infinity)).toEqual(false) + }) + + it('the value NaN is not a finite number', () => { + expect(isFiniteNumber(NaN)).toEqual(false) + }) + + it('anything else is not a finite number', () => { + expect(isFiniteNumber(undefined)).toEqual(false) + expect(isFiniteNumber(null)).toEqual(false) + expect(isFiniteNumber({})).toEqual(false) + expect(isFiniteNumber([])).toEqual(false) + expect(isFiniteNumber('123')).toEqual(false) + expect(isFiniteNumber('-0')).toEqual(false) + expect(isFiniteNumber(() => 123)).toEqual(false) + expect(isFiniteNumber(Symbol())).toEqual(false) + }) +}) diff --git a/giraffe/src/utils/isFiniteNumber.ts b/giraffe/src/utils/isFiniteNumber.ts new file mode 100644 index 00000000..9d2443ac --- /dev/null +++ b/giraffe/src/utils/isFiniteNumber.ts @@ -0,0 +1,4 @@ +import {isNumber} from './isNumber' + +export const isFiniteNumber = (value: any) => + value === value && isNumber(value) && Math.abs(value) !== Infinity diff --git a/stories/src/data/randomTable.ts b/stories/src/data/randomTable.ts new file mode 100644 index 00000000..04ebeefa --- /dev/null +++ b/stories/src/data/randomTable.ts @@ -0,0 +1,29 @@ +import {newTable} from '../../../giraffe/src/utils/newTable' +import memoizeOne from 'memoize-one' + +const now = Date.now() +const numberOfRecords = 80 +const recordsPerLine = 20 + +const TIME_COL = [] +const VALUE_COL = [] +const CPU_COL = [] + +function getRandomNumber(max) { + return Math.random() * Math.floor(max) - max / 2 +} + +export const getRandomTable = memoizeOne((maxValue: number) => { + for (let i = 0; i < numberOfRecords; i += 1) { + VALUE_COL.push(getRandomNumber(maxValue)) + CPU_COL.push(`cpu${Math.floor(i / recordsPerLine)}`) + TIME_COL.push(now + (i % recordsPerLine) * 1000 * 60) + } + + const randomTable = newTable(numberOfRecords) + .addColumn('_time', 'dateTime:RFC3339', 'time', TIME_COL) + .addColumn('_value', 'system', 'number', VALUE_COL) + .addColumn('cpu', 'string', 'string', CPU_COL) + + return randomTable +}) diff --git a/stories/src/linegraph.stories.tsx b/stories/src/linegraph.stories.tsx index 748026eb..b1282fb5 100644 --- a/stories/src/linegraph.stories.tsx +++ b/stories/src/linegraph.stories.tsx @@ -3,28 +3,136 @@ import {storiesOf} from '@storybook/react' import {withKnobs, number, select, boolean, text} from '@storybook/addon-knobs' import {Config, Plot, timeFormatter, fromFlux} from '../../giraffe/src' +import {getRandomTable} from './data/randomTable' import { PlotContainer, colorSchemeKnob, + fillKnob, findStringColumns, interpolationKnob, legendFontKnob, showAxesKnob, tickFontKnob, timeZoneKnob, + tooltipColorizeRowsKnob, + tooltipOrientationThresholdKnob, xKnob, xScaleKnob, yKnob, yScaleKnob, - tooltipOrientationThresholdKnob, - tooltipColorizeRowsKnob, } from './helpers' import {tooltipFalsyValues} from './data/fluxCSV' +const maxValue = Math.random() * Math.floor(200) + storiesOf('Line Graph', module) .addDecorator(withKnobs) + .add('User defined ticks', () => { + let table = getRandomTable(maxValue) + const xTickStart = number('xTickStart', new Date().getTime()) + const xTickStep = number('xTickStep', 200_000) + const xTotalTicks = number('xTotalTicks', 5) + const yTickStart = number('yTickStart') + const yTickStep = number('yTickStep') + const yTotalTicks = number('yTotalTicks', 8) + const timeFormat = select( + 'Time Format', + { + 'DD/MM/YYYY HH:mm:ss.sss': 'DD/MM/YYYY HH:mm:ss.sss', + 'MM/DD/YYYY HH:mm:ss.sss': 'MM/DD/YYYY HH:mm:ss.sss', + 'YYYY/MM/DD HH:mm:ss': 'YYYY/MM/DD HH:mm:ss', + 'YYYY-MM-DD HH:mm:ss ZZ': 'YYYY-MM-DD HH:mm:ss ZZ', + 'hh:mm a': 'hh:mm a', + 'HH:mm': 'HH:mm', + 'HH:mm:ss': 'HH:mm:ss', + 'HH:mm:ss ZZ': 'HH:mm:ss ZZ', + 'HH:mm:ss.sss': 'HH:mm:ss.sss', + 'MMMM D, YYYY HH:mm:ss': 'MMMM D, YYYY HH:mm:ss', + 'dddd, MMMM D, YYYY HH:mm:ss': 'dddd, MMMM D, YYYY HH:mm:ss', + }, + 'HH:mm:ss' + ) + const legendFont = legendFontKnob() + const tickFont = tickFontKnob() + const valueAxisLabel = text('Value Axis Label', 'foo') + const colors = colorSchemeKnob() + const x = xKnob(table) + const y = yKnob(table) + const xScale = xScaleKnob() + const yScale = yScaleKnob() + + const timeZone = timeZoneKnob() + const fill = fillKnob(table, ['cpu']) + const position = select( + 'Line Position', + {stacked: 'stacked', overlaid: 'overlaid'}, + 'overlaid' + ) + const interpolation = interpolationKnob() + const lineWidth = number('Line Width', 1) + const shadeBelow = boolean('Shade Area', false) + const shadeBelowOpacity = number('Area Opacity', 0.1) + const hoverDimension = select( + 'Hover Dimension', + {auto: 'auto', x: 'x', y: 'y', xy: 'xy'}, + 'auto' + ) + const legendOpacity = number('Legend Opacity', 1.0, { + range: true, + min: 0, + max: 1.0, + step: 0.05, + }) + const legendOrientationThreshold = tooltipOrientationThresholdKnob() + const legendColorizeRows = tooltipColorizeRowsKnob() + + const config: Config = { + table, + valueFormatters: { + _time: timeFormatter({timeZone, format: timeFormat}), + _value: val => + `${val.toFixed(2)}${ + valueAxisLabel ? ` ${valueAxisLabel}` : valueAxisLabel + }`, + }, + xScale, + yScale, + xTickStart, + xTickStep, + xTotalTicks, + yTickStart, + yTickStep, + yTotalTicks, + legendFont, + tickFont, + legendOpacity, + legendOrientationThreshold, + legendColorizeRows, + layers: [ + { + type: 'line', + x, + y, + fill, + position, + interpolation, + colors, + lineWidth, + hoverDimension, + shadeBelow, + shadeBelowOpacity, + }, + ], + } + + return ( + + + + ) + }) .add('Static CSV', () => { const staticData = select( 'Static CSV',