React-art is awesome, you can easily embody the same concepts in React and your visualizations magically work.
I personally have not done a ton of visualization, and the little I have done is mostly rendering graphs with D3.
I've been tasked with doing a dive for a difficult visualization. We ran into a scenario where we needed the ability to zoom and drag the canvas. D3 conveniently comes with zoom/drag behaviors. D3 integrates pretty well with react-art for doing a lot of the math/generating paths, however after watching React.js Conf - Scalable Data Visualization the things immediatley called out that D3 doesn't integrate with react-art are transitions and behaviors (zoom/drag).
So immediately I'm thinking about how to accomplish this. Do I need a global scaler that scales all of my coordinates for zooming? Do I need to manage a coordinate system and adjust all of my coordinates with the dragged X/Y offsets.
I googled around, and a few people recommended using canvasEl.getContext('2d').translate(x,y)
. I gave this a try with refs, that didn't work.
It did lead me down the right path though. What if I was able to just utilize one global wrapper, and all of my other code could remain unchanged. The great thing about Group
is that the coordinate system of the children gets reset, so 0,0
is now the x,y
of the Group
Example:
<Group x={100} y={100}> <Circle radius={10} stroke="#000" strokeWidth={3} x={20} y={20} /> </Group>
The coordinates of the circle on the whole canvas would actually be 120,120
but because of the group at x = 100, y = 100
we just need to say x = 20, y = 20
.
Now that we know that our parent coordinate system effects our child coordinate systems lets prove our final theory that we can have one master parent to control zoom/drag.
Lets start with a base renderer
//Assuming React, and react-art are included var ZoomDragCircle = React.createClass({ render: function () { return <Surface width={viewportWidth} height={viewportHeight}></Surface>; }, });
We have a surface so the next lets get something rendering
var ZoomDragCircle = React.createClass({ render: function() { return ( <Surface width={viewportWidth} height={viewportHeight} > <Circle x={10} y={10} radius={5} fill="#000" /> </Surface> ); } })
Lets add in our drag concept.
var ZoomDragCircle = React.createClass({ getInitialState: function () { return { x: 0, y: 0, }; }, handleMouseDown: function () { this.dragging = true; }, handleMouseUp: function () { this.dragging = false; }, render: function () { return ( <div onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> <Surface width={viewportWidth} height={viewportHeight}> <Group x={this.state.x} y={this.state.y}> <Circle x={10} y={10} radius={5} fill="#000" /> </Group> </Surface> </div> ); }, });
One thing you'll notice here is the wrapping div. The react-art
Surface
element doesn't have the EventMixin
so it will not register mouse events. We could wrap our Group
with another Group
for dragging/zoom however an outer div
is much easier for now.
You also may notice that we have a slight issue. onMouseUp
should be globally on the document
since the mouseup
event will only be fired if the mouseup
happens on our wrapping div. For simplicity sake we'll keep it on the div.
So we have a way to toggle whether we are dragging or not, and have the ability to adjust the x,y
coords of a parent group. Lets actually implement drag.
var ZoomDragCircle = React.createClass({ getInitialState: function () { return { x: 0, y: 0, }; }, componentDidMount: function () { document.addEventListener("mousemove", this.handleMouseMove, false); }, componentWillUnmount: function () { //Don't forget to unlisten! document.removeEventListener("mousemove", this.handleMouseMove, false); }, handleMouseDown: function (e) { this.dragging = true; //Set coords this.coords = { x: e.pageX, y: e.pageY, }; }, handleMouseUp: function () { this.dragging = false; this.coords = {}; }, handleMouseMove: function (e) { //If we are dragging if (this.dragging) { e.preventDefault(); //Get mouse change differential var xDiff = this.coords.x - e.pageX, yDiff = this.coords.y - e.pageY; //Update to our new coordinates this.coords.x = e.pageX; this.coords.y = e.pageY; //Adjust our x,y based upon the x/y diff from before var x = this.state.x - xDiff, y = this.state.y - yDiff; //Re-render this.setState(this.state); } }, render: function () { return ( <div onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> <Surface width={viewportWidth} height={viewportHeight}> <Group x={this.state.x} y={this.state.y}> <Circle x={10} y={10} radius={5} fill="#000" /> </Group> </Surface> </div> ); }, });
Now if you spin this up you'll see we can drag around the canvas and our Circle
will stay the same place.
Lets do zoom now.
To understand what we're about to do the Art library will translate our x,y
coords to a matrix
that is set on the transform
attribute of the svg g
element or in the canvas case translated to the appropriate coordinates.
The matrix
system can be read about here on MDN. Ultimately it allows us to modify the coordinate system (x,y
) and additionally the scale.
Think of scale as a default multiplier times the size of stuff.
So a scale of 1
means if something is a width of 10
then it would still be 10.
But If we set our scale to 2
and the same width of 10
then 10*2 = 20
. The item would appear larger at 20 pixels.
This is the rough idea behind scale, however we aren't adjusting widths the scale is actually effecting the x,y
coordinates you are setting. You can define scaleX
and scaleY
to be different numbers causing your visual elements to appear blurred/skewed.
var ZoomDragCircle = React.createClass({ getInitialState: function () { return { x: 0, y: 0, scale: 1, }; }, componentDidMount: function () { document.addEventListener("mousemove", this.handleMouseMove, false); }, componentWillUnmount: function () { //Don't forget to unlisten! document.removeEventListener("mousemove", this.handleMouseMove, false); }, handleMouseDown: function (e) { this.dragging = true; //Set coords this.coords = { x: e.pageX, y: e.pageY, }; }, handleMouseUp: function () { this.dragging = false; this.coords = {}; }, handleMouseMove: function (e) { //If we are dragging if (!this.dragging) { return; } e.preventDefault(); //Get mouse change differential var xDiff = this.coords.x - e.pageX, yDiff = this.coords.y - e.pageY; //Update to our new coordinates this.coords.x = e.pageX; this.coords.y = e.pageY; //Adjust our x,y based upon the x/y diff from before var x = this.state.x - xDiff, y = this.state.y - yDiff; //Re-render this.setState(this.state); }, //So we can handle the mousewheel returning -0 or 0 isNegative: function (n) { return ((n = +n) || 1 / n) < 0; }, handleMouseWheel: function (e) { var ZOOM_STEP = 0.03; //require the shift key to be pressed to scroll if (!e.shiftKey) { return; } e.preventDefault(); var direction = this.isNegative(e.deltaX) && this.isNegative(e.deltaY) ? "down" : "up"; if (direction == "up") { this.state.scale += ZOOM_STEP; } else { this.state.scale -= ZOOM_STEP; } this.state.scale = this.state.scale < 0 ? 0 : this.state.scale; this.setState(this.state); }, render: function () { return ( <div onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} onWheel={this.handleMouseWheel} > <Surface width={viewportWidth} height={viewportHeight}> <Group x={this.state.x} y={this.state.y} scaleX={this.state.scale} scaleY={this.state.scale} > <Circle x={10} y={10} radius={5} fill="#000" /> </Group> </Surface> </div> ); }, });
Now we should be able to zoom in/zoom out while holding shift key + using your scroll wheel.
If you want a predictable scale you can add some +
and -
buttons somewhere and just increment this.state.scale
I'm hoping to do more write ups and examples with react-art. The great thing is that you can render react-art with react-native. With appropriate abstractions you could possibly have the same visualizations on the web as you do on native.