diff --git a/.travis.yml b/.travis.yml index 9cb552eb..40b4523e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ef8fa26a..e2d92dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,25 +28,25 @@ ## 1.0.0 -* 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) +- 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) diff --git a/README.md b/README.md index 809803cd..b265f6ed 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/configuration.md b/docs/configuration.md index 01c697f2..3ae44765 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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: @@ -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: diff --git a/src/config.js b/src/config.js index 5782aa5c..5254d5c3 100644 --- a/src/config.js +++ b/src/config.js @@ -59,6 +59,7 @@ export default d3 => ({ onZoomEnd: null, minimumScale: 0, maximumScale: Infinity, + restrictPan: false, }, numberDisplayedTicks: { small: 3, diff --git a/src/index.js b/src/index.js index d1ab4458..ebbba39a 100644 --- a/src/index.js +++ b/src/index.js @@ -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'; @@ -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) { @@ -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(); diff --git a/src/zoom.js b/src/zoom.js index d85b2e29..989b9e00 100644 --- a/src/zoom.js +++ b/src/zoom.js @@ -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); diff --git a/src/zoom.spec.js b/src/zoom.spec.js index 9c04c788..c2d3da26 100644 --- a/src/zoom.spec.js +++ b/src/zoom.spec.js @@ -1,4 +1,4 @@ -import zoomFactory, { getShiftedTransform } from './zoom'; +import zoomFactory, { getShiftedTransform, getDomainTransform } from './zoom'; const defaultConfig = { label: {}, @@ -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, @@ -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, @@ -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');