diff --git a/README.md b/README.md index 61de0f27..809803cd 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,13 @@ You can either use D3 as a specific import (specifying it in first argument of ` **[Configuration Reference](./docs/configuration.md)** -In addition to this configuration object, it also exposes some public methods allowing you to customize your application based on filtered data: +In addition to this configuration object, it also exposes some public members allowing you to customize your application based on filtered data: -* **scale()** provides the horizontal scale, allowing you to retrieve bounding dates thanks to `.scale().domain()`, -* **filteredData()** returns an object with both `data` and `fullData` keys containing respectively bounds filtered data and full dataset. -* **draw(config, scale)** redraw chart using given configuration and `d3.scaleTime` scale +* **scale()** provides the horizontal scale, allowing you to retrieve bounding dates thanks to `.scale().domain()`, +* **filteredData()** returns an object with both `data` and `fullData` keys containing respectively bounds filtered data and full dataset. +* **draw(config, scale)** redraws chart using given configuration and `d3.scaleTime` scale +* **destroy()** execute this function before to removing the chart from DOM. It prevents some memory leaks due to event listeners. +* **currentBreakpointLabel** returns current breakpoint (for instance `small`) among a [list of breakpoints](./docs/configuration.md#breakpoints). Hence, if you want to display number of displayed data and time bounds as in the [demo](https://marmelab.com/EventDrops/), you can use the following code: diff --git a/docs/configuration.md b/docs/configuration.md index cf1e876c..01c697f2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -344,3 +344,37 @@ This parameter configures the minimum zoom level available. Set it to a not-null _Default: Infinity_ This parameter configures the maximum zoom level available. Set it to a lower value to prevent your users from zooming in too deeply. + +### numberDisplayedTicks + +\_Default: + +```js +const chart = eventDrops({ + numberDisplayedTicks: { + small: 3, + medium: 5, + large: 7, + extra: 12, + }, +}); +``` + +When reducing chart width, we need to display less labels on the horizontal axis to keep a readable chart. This parameter aims to solve the issue. Hence, on smallest devices, it displays only 3 labels by default at the same time. + +### breakpoints + +\_Default: + +```js +const chart = eventDrops({ + breakpoints: { + small: 576, + medium: 768, + large: 992, + extra: 1200, + }, +}); +``` + +When reducing chart width, we need to display less labels on the horizontal axis to keep a readable chart. This parameter aims to solve the issue. Hence, we can define breakpoints in pixels. diff --git a/src/axis.js b/src/axis.js index 82b144d4..6e421cdd 100644 --- a/src/axis.js +++ b/src/axis.js @@ -32,8 +32,13 @@ export const tickFormat = (date, formats, d3) => { return d3.timeFormat(formats.year)(date); }; -export default (d3, config, xScale) => { - const { label: { width: labelWidth }, axis: { formats }, locale } = config; +export default (d3, config, xScale, breakpointLabel) => { + const { + label: { width: labelWidth }, + axis: { formats }, + locale, + numberDisplayedTicks, + } = config; d3.timeFormatDefaultLocale(locale); return selection => { const axis = selection.selectAll('.axis').data(d => d); @@ -42,7 +47,8 @@ export default (d3, config, xScale) => { const axisTop = d3 .axisTop(xScale) - .tickFormat(d => tickFormat(d, formats, d3)); + .tickFormat(d => tickFormat(d, formats, d3)) + .ticks(numberDisplayedTicks[breakpointLabel]); axis .enter() diff --git a/src/axis.spec.js b/src/axis.spec.js index 54723e28..4776c327 100644 --- a/src/axis.spec.js +++ b/src/axis.spec.js @@ -19,9 +19,13 @@ const defaultConfig = { year: '%Y', }, }, + numberDisplayedTicks: { + extra: 12, + }, }; const defaultScale = d3.scaleTime(); +const defaultBreakpointLabel = 'extra'; describe('Axis Tick Format', () => { it('should format date correctly depending of current granularity', () => { @@ -50,21 +54,27 @@ describe('Axis', () => { it('should add a group with class "axis"', () => { const selection = d3.select('svg').data([[{}]]); - axis(d3, defaultConfig, defaultScale)(selection); + axis(d3, defaultConfig, defaultScale, defaultBreakpointLabel)( + selection + ); expect(document.querySelector('.axis')).toBeTruthy(); }); it('should append only a single group regardless number of given data', () => { const selection = d3.select('svg').data([[{}, {}, {}]]); - axis(d3, defaultConfig, defaultScale)(selection); + axis(d3, defaultConfig, defaultScale, defaultBreakpointLabel)( + selection + ); expect(document.querySelectorAll('.axis').length).toBe(1); }); it('should be translated of `label.width` to the right', () => { const selection = d3.select('svg').data([[{}, {}, {}]]); - axis(d3, defaultConfig, defaultScale)(selection); + axis(d3, defaultConfig, defaultScale, defaultBreakpointLabel)( + selection + ); expect(document.querySelector('.axis').getAttribute('transform')).toBe( 'translate(200,0)' ); @@ -74,19 +84,26 @@ describe('Axis', () => { const selection = d3.select('svg').data([[{}, {}]]); const axisTopSpy = jest.spyOn(d3, 'axisTop'); - axis(d3, defaultConfig, defaultScale)(selection); + axis(d3, defaultConfig, defaultScale, defaultBreakpointLabel)( + selection + ); expect(axisTopSpy).toHaveBeenCalledWith(defaultScale); }); it('should use tick formats passed in configuration', () => { - const tickFormatSpy = jest.fn(() => () => {}); + const ticksSpy = jest.fn(() => () => {}); + const tickFormatSpy = jest.fn(() => ({ + ticks: ticksSpy, + })); jest.spyOn(d3, 'axisTop').mockImplementation(() => ({ tickFormat: tickFormatSpy, })); const data = [[{ id: 'foo' }]]; const selection = d3.select('svg').data(data); - axis(d3, defaultConfig, defaultScale)(selection); + axis(d3, defaultConfig, defaultScale, defaultBreakpointLabel)( + selection + ); const tickFormat = tickFormatSpy.mock.calls[0][0]; expect(tickFormat(new Date('2017-01-01 12:00:00'))).toBe('12 PM'); @@ -95,10 +112,14 @@ describe('Axis', () => { it('should remove "axis" group when data is removed', () => { const data = [[{ id: 'foo' }]]; const selection = d3.select('svg').data(data); - axis(d3, defaultConfig, defaultScale)(selection); + axis(d3, defaultConfig, defaultScale, defaultBreakpointLabel)( + selection + ); selection.data([[]]); - axis(d3, defaultConfig, defaultScale)(selection); + axis(d3, defaultConfig, defaultScale, defaultBreakpointLabel)( + selection + ); expect(document.querySelectorAll('.axis').length).toBe(0); }); @@ -115,11 +136,24 @@ describe('Axis', () => { const data = [[{ id: 'foo' }]]; const selection = d3.select('svg').data(data); - axis(d3, config, defaultScale)(selection); + axis(d3, config, defaultScale, defaultBreakpointLabel)(selection); expect(timeFormatDefaultLocaleSpy).toHaveBeenCalledWith(defaultLocale); }); + it('should display number of ticks passed in configuration', () => { + const data = [[{ id: 'foo' }]]; + const selection = d3.select('svg').data(data); + + let config = { + ...defaultConfig, + numberDisplayedTicks: { extra: 9 }, + }; + + axis(d3, config, defaultScale, defaultBreakpointLabel)(selection); + expect(document.querySelectorAll('.tick').length).toBe(9); + }); + afterEach(() => { document.body.innerHTML = ''; jest.restoreAllMocks(); diff --git a/src/breakpoint.js b/src/breakpoint.js new file mode 100644 index 00000000..0cb9b741 --- /dev/null +++ b/src/breakpoint.js @@ -0,0 +1,9 @@ +export const getBreakpointLabel = (breakpoints, point) => { + for (const label in breakpoints) { + if (point <= breakpoints[label]) { + return label; + } + } + + return 'extra'; +}; diff --git a/src/breakpoint.spec.js b/src/breakpoint.spec.js new file mode 100644 index 00000000..27ff7ef1 --- /dev/null +++ b/src/breakpoint.spec.js @@ -0,0 +1,27 @@ +import { getBreakpointLabel } from './breakpoint'; + +const defaultConfig = { + breakpoints: { + small: 576, + medium: 768, + large: 992, + extra: 1200, + }, +}; + +describe('Breakpoint Label', () => { + it('should return breakpoint label correctly depending of current point', () => { + expect(getBreakpointLabel(defaultConfig.breakpoints, 400)).toBe( + 'small' + ); + expect(getBreakpointLabel(defaultConfig.breakpoints, 600)).toBe( + 'medium' + ); + expect(getBreakpointLabel(defaultConfig.breakpoints, 800)).toBe( + 'large' + ); + expect(getBreakpointLabel(defaultConfig.breakpoints, 1000)).toBe( + 'extra' + ); + }); +}); diff --git a/src/config.js b/src/config.js index d5061681..5782aa5c 100644 --- a/src/config.js +++ b/src/config.js @@ -60,4 +60,16 @@ export default d3 => ({ minimumScale: 0, maximumScale: Infinity, }, + numberDisplayedTicks: { + small: 3, + medium: 5, + large: 7, + extra: 12, + }, + breakpoints: { + small: 576, + medium: 768, + large: 992, + extra: 1200, + }, }); diff --git a/src/index.js b/src/index.js index dff7cd61..c538cda6 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ import defaultsDeep from 'lodash.defaultsdeep'; import axis from './axis'; +import { getBreakpointLabel } from './breakpoint'; import bounds from './bounds'; import defaultConfiguration from './config'; import dropLine from './dropLine'; @@ -12,7 +13,12 @@ import { withinRange } from './withinRange'; // do not export anything else here to keep window.eventDrops as a function export default ({ d3 = window.d3, ...customConfiguration }) => { - const chart = selection => { + const initChart = selection => { + selection.selectAll('svg').remove(); + + const root = selection.selectAll('svg').data(selection.data()); + root.exit().remove(); + const config = defaultsDeep( customConfiguration || {}, defaultConfiguration(d3) @@ -27,6 +33,7 @@ export default ({ d3 = window.d3, ...customConfiguration }) => { line: { height: lineHeight }, range: { start: rangeStart, end: rangeEnd }, margin, + breakpoints, } = config; const getEvent = () => d3.event; // keep d3.event mutable see https://github.com/d3/d3/issues/2733 @@ -40,10 +47,10 @@ export default ({ d3 = window.d3, ...customConfiguration }) => { .range([0, width - labelWidth]); chart._scale = xScale; - - const root = selection.selectAll('svg').data(selection.data()); - - root.exit().remove(); + chart.currentBreakpointLabel = getBreakpointLabel( + breakpoints, + global.innerWidth + ); const svg = root .enter() @@ -73,8 +80,19 @@ export default ({ d3 = window.d3, ...customConfiguration }) => { .call(draw(config, xScale)); }; + const chart = selection => { + chart._initialize = () => initChart(selection); + chart._initialize(); + + global.addEventListener('resize', chart._initialize, true); + }; + chart.scale = () => chart._scale; chart.filteredData = () => chart._filteredData; + chart.destroy = (callback = () => {}) => { + global.removeEventListener('resize', chart._initialize, true); + callback(); + }; const draw = (config, scale) => selection => { const { drop: { date: dropDate } } = config; @@ -112,7 +130,7 @@ export default ({ d3 = window.d3, ...customConfiguration }) => { .data(filteredData) .call(dropLine(config, scale)) .call(bounds(config, scale)) - .call(axis(d3, config, scale)); + .call(axis(d3, config, scale, chart.currentBreakpointLabel)); }; chart.draw = draw; diff --git a/src/index.spec.js b/src/index.spec.js index 96cd1bf2..db16fc03 100644 --- a/src/index.spec.js +++ b/src/index.spec.js @@ -16,6 +16,7 @@ const defaultConfig = { margin: {}, axis: {}, locale: {}, + numberDisplayedTicks: {}, }; describe('EventDrops', () => { @@ -175,6 +176,15 @@ describe('EventDrops', () => { ]); }); + it('should give access to current breakpoint label', () => { + const chart = EventDrops(defaultConfig); + + const root = d3.select('div').data([[{ data: [] }]]); + root.call(chart); + + expect(chart.currentBreakpointLabel).toEqual('extra'); + }); + afterEach(() => { document.body.innerHTML = ''; jest.restoreAllMocks();