Skip to content

Commit

Permalink
Merge pull request #249 from marmelab/responsive-chart
Browse files Browse the repository at this point in the history
[RFR] Responsive chart
  • Loading branch information
jpetitcolas authored Jul 12, 2018
2 parents 08ba93e + f18f262 commit b5344db
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 22 deletions.
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
34 changes: 34 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 9 additions & 3 deletions src/axis.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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()
Expand Down
52 changes: 43 additions & 9 deletions src/axis.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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)'
);
Expand All @@ -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');
Expand All @@ -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);
});

Expand All @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions src/breakpoint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const getBreakpointLabel = (breakpoints, point) => {
for (const label in breakpoints) {
if (point <= breakpoints[label]) {
return label;
}
}

return 'extra';
};
27 changes: 27 additions & 0 deletions src/breakpoint.spec.js
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
12 changes: 12 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
30 changes: 24 additions & 6 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const defaultConfig = {
margin: {},
axis: {},
locale: {},
numberDisplayedTicks: {},
};

describe('EventDrops', () => {
Expand Down Expand Up @@ -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();
Expand Down

0 comments on commit b5344db

Please sign in to comment.