Skip to content

Commit

Permalink
fix ZoomContainer handling for controlled zoom container, add prop docs
Browse files Browse the repository at this point in the history
  • Loading branch information
Dan Delany committed Apr 10, 2018
1 parent edbb5a8 commit 997d5ae
Show file tree
Hide file tree
Showing 5 changed files with 158 additions and 27 deletions.
1 change: 1 addition & 0 deletions docs/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const lessons = [

const mainComponents = [
{name: 'XYPlot', path: '/xy-plot', Component: Docs.XYPlotDocs},
{name: 'ZoomContainer', path: '/zoom-container', Component: Docs.ZoomContainerDocs}
];

const xyChartComponents = [
Expand Down
5 changes: 5 additions & 0 deletions docs/src/docs/ZoomContainer/ZoomContainerDocs.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ const examples = [
label: "Basic ZoomContainer",
codeText: require('raw-loader!./examples/ZoomContainer.js.example'),
},
{
id: "controlled",
label: "Controlled ZoomContainer",
codeText: require('raw-loader!./examples/ZoomContainerControlled.js.example'),
},
];

export default class ZoomContainerExamples extends React.Component {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const ZoomContainerExample = (props) => {
return <div>
<ZoomContainer width={600} height={350} onZoom={handleZoom}>
<ZoomContainer width={600} height={350}>
<XYPlot scaleType="linear" width={600} height={350}>
<XAxis title="Phase" />
<YAxis title="Intensity" />
Expand All @@ -14,8 +14,4 @@ const ZoomContainerExample = (props) => {
</div>
};

function handleZoom(...args) {
console.log(args);
}

ReactDOM.render(<ZoomContainerExample />, mountNode);
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
class ZoomContainerControlledExample extends React.Component {
class ZoomControlledExample extends React.Component {
state = {
zoomTransform: {k: 1, x: 0, y: 0},
zoomX: 0,
Expand Down Expand Up @@ -50,4 +50,4 @@ class ZoomContainerControlledExample extends React.Component {
}
}

ReactDOM.render(<ZoomContainerControlledExample />, mountNode);
ReactDOM.render(<ZoomControlledExample />, mountNode);
169 changes: 149 additions & 20 deletions src/ZoomContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,121 @@ import * as d3 from "d3";

// todo: make sure this correctly handles new props getting passed in, doesn't double bind events

function zoomTransformFromProps(props) {
const {zoomScale, zoomX, zoomY} = props;
return d3.zoomIdentity.translate(zoomX || 0, zoomY || 0).scale(zoomScale || 1);
}

export default class ZoomContainer extends React.Component {
static propTypes = {
/**
* (outer) width of the chart (SVG element).
*/
width: PropTypes.number,
/**
* (outer) width of the chart (SVG element).
*/
height: PropTypes.number,
standalone: PropTypes.func,
customZoon: PropTypes.bool,

/**
* Zoom callback function, called when zoom changes.
* For controlled version of this component, you should update zoomX, zoomY and zoomScale props in this callback.
*/
onZoom: PropTypes.func,
/**
* Boolean which determines whether the component is "controlled" (true) or "stateful" (false).
* When true, zoom transformation is controlled entirely by the `zoomX`, `zoomY` and `zoomScale` props, which
* you are responsible for updating in the `onZoom` callback function.
* When false, zoom transformation is handled by internal state, and the `zoomX`, `zoomY` and `zoomScale` props
* specify only the initial X, Y and scale transformation of the component.
*/
controlled: PropTypes.bool,
/**
* The X-coordinate of the zoom transformation (or initial X-coordinate, if `controlled` is false)
*/
zoomX: PropTypes.number,
/**
* The Y-coordinate of the zoom transformation (or initial Y-coordinate, if `controlled` is false)
*/
zoomY: PropTypes.number,
/**
* The scaling factor of the zoom transformation (or initial scaling, if `controlled` is false).
* 1.0 is normal size, 2.0 is double size, 0.5 is half size.
*/
zoomScale: PropTypes.number,

/**
* Sets the viewport extent to the specified array of points [[x0, y0], [x1, y1]],
* where [x0, y0] is the top-left corner of the viewport and [x1, y1] is the bottom-right corner of the viewport.
* See d3-zoom docs for more information.
*/
extent: PropTypes.array,
/**
* Sets the scale extent to the specified array of numbers [k0, k1]
* where k0 is the minimum allowed scale factor and k1 is the maximum allowed scale factor.
* See d3-zoom docs for more information.
*/
scaleExtent: PropTypes.array,
/**
* Sets the translate extent to the specified array of points [[x0, y0], [x1, y1]],
* where [x0, y0] is the top-left corner of the world and [x1, y1] is the bottom-right corner of the world.
* See d3-zoom docs for more information.
*/
translateExtent: PropTypes.array,
/**
* Sets the maximum distance that the mouse can move between mousedown and mouseup that will trigger
* a subsequent click event.
* See d3-zoom docs for more information.
*/
clickDistance: PropTypes.number,
/**
* Sets the duration for zoom transitions on double-click and double-tap to the specified number of milliseconds.
* See d3-zoom docs for more information.
*/
duration: PropTypes.number,
/**
* Sets the interpolation factory for zoom transitions to the specified function.
* See d3-zoom docs for more information.
*/
interpolate: PropTypes.func,
/**
* Sets the transform constraint function to the specified function.
* See d3-zoom docs for more information.
*/
constrain: PropTypes.func,
/**
* Sets the zoom event filter to the specified function.
* See d3-zoom docs for more information.
*/
filter: PropTypes.func,
/**
* Sets the touch support detector to the specified function.
* See d3-zoom docs for more information.
*/
touchable: PropTypes.func,
/**
* Sets the wheel delta function to the specified function.
* See d3-zoom docs for more information.
*/
wheelDelta: PropTypes.func
};
static defaultProps = {
width: 800,
height: 600
height: 600,
controlled: false,
zoomX: 0,
zoomY: 0,
zoomScale: 1
};

state = {
zoomTransform: null
lastZoomTransform: null,
selection: null
};

_initZoom() {
this.zoom = d3.zoom().on("zoom", this.handleZoom);

const {extent, scaleExtent, translateExtent, clickDistance, duration, interpolate} = this.props;
const {constrain, filter, touchable, wheelDelta} = this.props;
_updateZoomProps(props) {
if (!props) props = this.props;
const {extent, scaleExtent, translateExtent, clickDistance, duration, interpolate} = props;
const {constrain, filter, touchable, wheelDelta} = props;

if (_.isArray(extent)) this.zoom.extent(extent);
if (_.isArray(scaleExtent)) this.zoom.scaleExtent(scaleExtent);
Expand All @@ -52,25 +134,72 @@ export default class ZoomContainer extends React.Component {
}

componentDidMount() {
this._initZoom();
d3.select(this.refs.svg).call(this.zoom);
const initialZoomTransform = zoomTransformFromProps(this.props);
const selection = d3.select(this.refs.svg);

this.zoom = d3.zoom();
selection.call(this.zoom);
this.zoom.transform(selection, initialZoomTransform);
this._updateZoomProps();
this.zoom.on("zoom", this.handleZoom);

this.setState({
selection,
lastZoomTransform: initialZoomTransform
});
}
componentDidUpdate() {
this._initZoom();
d3.select(this.refs.svg).call(this.zoom);

// React is deprecating componentWillReceiveProps, but it's pretty much necessary in this case
// TODO: change to UNSAFE_componentWillReceiveProps when upgrading React
componentWillReceiveProps(nextProps) {
if (this.props.controlled) {
// if controlled component and zoom props have changed, apply the new zoom props to d3-zoom
// (unbind handler first so as not to create infinite callback loop)
const hasChangedZoom =
nextProps.zoomX !== this.props.zoomX ||
nextProps.zoomY !== this.props.zoomY ||
nextProps.zoomScale !== this.props.zoomScale;

if(hasChangedZoom) {
this.zoom.on("zoom", null);
const nextZoomTransform = zoomTransformFromProps(nextProps);
this.zoom.transform(this.state.selection, nextZoomTransform);
this.zoom.on("zoom", this.handleZoom);

// update state.lastZoomTransform so we can revert d3-zoom to this next time it's changed internally
this.setState({lastZoomTransform: nextZoomTransform});
}
}
this._updateZoomProps(nextProps);
}

handleZoom = (...args) => {
this.setState({
zoomTransform: d3.event.transform
});
if (this.props.onZoom) this.props.onZoom(...args);
const nextZoomTransform = d3.event.transform;

if (this.props.controlled) {
// zoom transform should be controlled by props, but d3-zoom has already applied new transform to this.zoom
// (even though props haven't changed), so we must *undo* it by applying lastZoomTransform to this.zoom
const {selection, lastZoomTransform} = this.state;

// unbind zoom event first, so that manually setting transform doesn't trigger handleZoom infinite loop
this.zoom.on("zoom", null);
this.zoom.transform(selection, lastZoomTransform);
this.zoom.on("zoom", this.handleZoom);
} else {
// *uncontrolled* (stateful) ZoomContainer, we want to keep the transform applied by d3-zoom;
// but since the state is inside d3-zoom, we need to update something on this.state to trigger re-render
this.setState({zoomKey: Math.random()});
}

if (this.props.onZoom) this.props.onZoom(nextZoomTransform, ...args);
};

render() {
const zoomTransform = this.refs.svg ? d3.zoomTransform(this.refs.svg) : null;

return (
<svg ref="svg" width={this.props.width} height={this.props.height}>
<g width={this.props.width} height={this.props.height} transform={this.state.zoomTransform}>
<g width={this.props.width} height={this.props.height} transform={zoomTransform}>
{this.props.children}
</g>
</svg>
Expand Down

0 comments on commit 997d5ae

Please sign in to comment.