Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Zoom improvements #269

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
sudo: false
dist: xenial
language: node_js
node_js:
- 6.9
- 10.15.3
addons:
firefox: "50.0.2"
firefox: latest
cache:
directories:
- node_modules
Expand All @@ -14,8 +15,8 @@ install:
- npm install
before_script:
- "export DISPLAY=:99.0"
- "sh -e /etc/init.d/xvfb start"
- sleep 3 # give xvfb some time to start
- "/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16"
script:
- make test
services:
- xvfb
28 changes: 14 additions & 14 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,25 +28,25 @@

## 1.0.0

* Huge performance boost (almost 10 times faster when zooming or panning)
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
* Review configuration to get more intuitive naming
* Simplify tick format configuration passing only time formats instead of a whole function
* Fix zoom and panning center
* Better integration with module bundlers (allowing to pass a local D3 object instead of the global one)
- Huge performance boost (almost 10 times faster when zooming or panning)
- Review configuration to get more intuitive naming
- Simplify tick format configuration passing only time formats instead of a whole function
- Fix zoom and panning center
- Better integration with module bundlers (allowing to pass a local D3 object instead of the global one)

We took profit of this major version change to improve the API - unfortunately, we couldn't keep backwards compatibility. See the [migration guide](./MIGRATION-4.0.md) for more informations.

## 0.3.0

* API Change: The data for each event line object must now be in the `data` property (was `date`).
* Pass any data object to each drop and specify the date property with a callback.
* The SVG is now responsive and fit with its parent
* Rename `eventHover`, `eventClick` and `eventZoom` events to `mouseover`, `click` and `zoomend` respectively.
* Adding `mouseout` handler
- API Change: The data for each event line object must now be in the `data` property (was `date`).
- Pass any data object to each drop and specify the date property with a callback.
- The SVG is now responsive and fit with its parent
- Rename `eventHover`, `eventClick` and `eventZoom` events to `mouseover`, `click` and `zoomend` respectively.
- Adding `mouseout` handler

## 0.2.0

* Display metaballs by default instead of simple dots
* Adding `eventClick` event handler on drops
* Use of Webpack instead of Babel for development tasks
* Full rewrite of the code base for better code splitting (may cause some BC breaks)
- Display metaballs by default instead of simple dots
- Adding `eventClick` event handler on drops
- Use of Webpack instead of Babel for development tasks
- Full rewrite of the code base for better code splitting (may cause some BC breaks)
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,12 @@ You can either use D3 as a specific import (specifying it in first argument of `

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)** 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).
* **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
* **zoomToDomain(domain, duration = 0, delay = 0, ease = d3.easeLinear)** programmatically zooms to domain, where domain is `[date, date]` (leftmost date, rightmost date). Ignores [restrictPan](./docs/configuration.md#restrictpan) modifier; if set to true, the function can still zoom out of restriction. By default there is no transition as duration is 0, however this can be tweaked to allow for a more visual appealing zoom.
* **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
10 changes: 8 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,13 @@ _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
### restrictPan

_Default: false_

If set to `true` will restrict panning (dragging behaviour) to the initial date range. If minimumZoom is set to less than 1, the date range can be zoomed out be larger than the initial. However, after the zoom is less than 1, the pan behaviour is disabled.

## numberDisplayedTicks

\_Default:

Expand All @@ -362,7 +368,7 @@ const chart = eventDrops({

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
## breakpoints

\_Default:

Expand Down
1 change: 1 addition & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default d3 => ({
onZoomEnd: null,
minimumScale: 0,
maximumScale: Infinity,
restrictPan: false,
},
numberDisplayedTicks: {
small: 3,
Expand Down
49 changes: 47 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { getBreakpointLabel } from './breakpoint';
import bounds from './bounds';
import defaultConfiguration from './config';
import dropLine from './dropLine';
import zoom from './zoom';
import zoomFactory from './zoom';
import { getDomainTransform } from './zoom';
import { addMetaballsDefs } from './metaballs';

import './style.css';
Expand Down Expand Up @@ -62,8 +63,38 @@ export default ({
.attr('width', width)
.classed('event-drop-chart', true);

const height = parseFloat(svg.style('height'));

if (zoomConfig) {
svg.call(zoom(d3, svg, config, xScale, draw, getEvent));
const zoom = d3.zoom();
svg.call(
zoomFactory(
d3,
svg,
config,
zoom,
xScale,
draw,
getEvent,
width,
height
)
);

chart._zoomToDomain = (domain, duration, delay, ease) => {
const zoomIdentity = getDomainTransform(
d3,
config,
domain,
xScale,
width
);
svg.transition()
.ease(ease)
.delay(delay)
.duration(duration)
.call(zoom.transform, zoomIdentity);
};
}

if (metaballs) {
Expand All @@ -90,6 +121,20 @@ export default ({

chart.scale = () => chart._scale;
chart.filteredData = () => chart._filteredData;
chart.zoomToDomain = (
domain,
duration = 0,
delay = 0,
ease = d3.easeLinear
) => {
if (typeof chart._zoomToDomain === 'function') {
chart._zoomToDomain(domain, duration, delay, ease);
} else {
throw new Error(
'Calling "zoomToDomain" requires zooming to be enabled.'
);
}
};
chart.destroy = (callback = () => {}) => {
global.removeEventListener('resize', chart._initialize, true);
callback();
Expand Down
62 changes: 59 additions & 3 deletions src/zoom.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,69 @@ export const getShiftedTransform = (
.translate(labelsWidth + labelsPadding, 0); // put origin at its original position
};

export default (d3, svg, config, xScale, draw, getEvent) => {
/**
* Given a domain, return a zoomIdentity (transformation) which can be called to zoom to that domain.
* Translates in reverse direction of the labels before applying the zoom and resets after,
* which factors out the label when creating zoom.
*
* @param {Object} d3 d3 object
* @param {Object} config configuration
* @param {Object[]} domain `[date, date]` where first and second is first date and last to zoom to respectively
* @param {Object} xScale a d3 scaleTime
* @param {number} width Width of the chart
* @returns {Object} transform object with x, y, and k (scale)
*
* @see https://github.com/d3/d3-zoom#zoomIdentity
* @example
* const transform = getDomainTransform(d3, config, domain, xScale, 1000);
* //transform: { x: 1.234, y: 0.323, k: 2.34 }
*/
export function getDomainTransform(d3, config, domain, xScale, width) {
const { label: { width: labelsWidth, padding: labelsPadding } } = config;

const fullLabelWidth = labelsWidth + labelsPadding;
// For the reason of two additional translate see getShiftedTransform for explanation
return d3.zoomIdentity
.translate(fullLabelWidth, 0)
.scale((width - labelsWidth) / (xScale(domain[1]) - xScale(domain[0])))
.translate(-xScale(domain[0]), 0)
.translate(-fullLabelWidth, 0);
}

export default (
d3,
svg,
config,
zoom,
xScale,
draw,
getEvent,
width,
height
) => {
const {
label: { width: labelsWidth, padding: labelsPadding },
zoom: { onZoomStart, onZoom, onZoomEnd, minimumScale, maximumScale },
zoom: {
onZoomStart,
onZoom,
onZoomEnd,
minimumScale,
maximumScale,
restrictPan,
},
} = config;

const zoom = d3.zoom().scaleExtent([minimumScale, maximumScale]);
const extentConstraint = [
[labelsWidth + labelsPadding, 0],
[width, height],
];

zoom.scaleExtent([minimumScale, maximumScale]);

//Restricts the pan area to be the specified start/end dates or initial if not set
if (restrictPan) {
zoom.translateExtent(extentConstraint).extent(extentConstraint);
}

zoom.on('zoom.start', onZoomStart).on('zoom.end', onZoomEnd);

Expand Down
75 changes: 72 additions & 3 deletions src/zoom.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import zoomFactory, { getShiftedTransform } from './zoom';
import zoomFactory, { getShiftedTransform, getDomainTransform } from './zoom';

const defaultConfig = {
label: {},
Expand All @@ -10,7 +10,7 @@ describe('Zoom', () => {
document.body.appendChild(document.createElement('svg'));
});

describe('getShiftedTransform', () => {
it('should correct shifted transform given original transform', () => {
const originalTransform = {
x: -120,
y: 0,
Expand All @@ -28,6 +28,39 @@ describe('Zoom', () => {
);
});

it('should transform correctly given domain', () => {
const config = {
...defaultConfig,
zoom: {
minimumScale: 15,
maximumScale: 25,
},
label: { width: 100, padding: 50 },
};

const rangeStartEnd = [new Date(2016, 0, 1), new Date(2019, 0, 1)];
const xScale = d3
.scaleTime()
.domain(rangeStartEnd)
.range([0, 100]);

const width = 400;
const domain = [new Date(2017, 0, 1), new Date(2018, 0, 1)];
const zoomIdentity = getDomainTransform(
d3,
config,
domain,
xScale,
width
);

expect(zoomIdentity).toEqual({
k: 9.008219178082191,
x: -1502.054794520548,
y: 0,
});
});

it('should set scale extent based on given configuration', () => {
const config = {
...defaultConfig,
Expand All @@ -37,11 +70,47 @@ describe('Zoom', () => {
},
};

const zoomObject = d3.zoom();
const selection = d3.select('svg');
const zoom = zoomFactory(d3, selection, config);
const zoom = zoomFactory(d3, selection, config, zoomObject);
expect(zoom.scaleExtent()).toEqual([15, 25]);
});

it('should set translate extent if restrictPan is true', () => {
const test = (config, translateExtent) => {
const width = 500,
height = 300;

const selection = d3.select('svg');
const zoomRestrict = zoomFactory(
d3,
selection,
config,
d3.zoom(),
{},
{},
{},
width,
height
);

expect(zoomRestrict.translateExtent()).toEqual(translateExtent);
};

const config = {
...defaultConfig,
label: {
width: 100,
padding: 20,
},
};

test(config, [[-Infinity, -Infinity], [Infinity, Infinity]]);

config.zoom.restrictPan = true;
test(config, [[120, 0], [500, 300]]);
});

/* These tests are skipped as I can't find any way to test D3 event at this point. */
it('should update scale according to given D3 zoom event');
it('should redraw chart using newly zoomed scale');
Expand Down