How to implement D3 for Vue.js - d3.js

There are various implementations of D3 with React. One of the more interesting ones uses the react-faux-dom project. Advantages to this approach are that React knows about DOM elements created by D3 and the ability to create isomorphic charts.
Refer to the following:
http://oli.me.uk/2015/09/09/d3-within-react-the-right-way/
https://github.com/Olical/react-faux-dom
http://www.reactd3.org/
What would it take to implement D3 in Vue.js with the same benefits?
Is there a need to create something similar to react-faux-dom or does Vue already have something that can be used for this?
How does this approach make sense (or not) considering Vue’s architecture?

Since version 4, D3 is highly modularized and computational parts are well isolated in small librairies, such as d3-force. One approach I like is to let Vue.js handle DOM manipulation and events, and use d3.js for computations. Your visualization component is similar to your other components, and are easier to understand for someone familiar with Vue.js but not d3.js.
I created a codepen to show a force graph implementation :
HTML :
<div id="app">
<svg xmlns="http://www.w3.org/2000/svg" :width="width+'px'" :height="height+'px'" #mousemove="drag($event)" #mouseup="drop()" v-if="bounds.minX">
<line v-for="link in graph.links" :x1="coords[link.source.index].x" :y1="coords[link.source.index].y" :x2="coords[link.target.index].x" :y2="coords[link.target.index].y" stroke="black" stroke-width="2"/>
<circle v-for="(node, i) in graph.nodes" :cx="coords[i].x" :cy="coords[i].y" :r="20" :fill="colors[Math.ceil(Math.sqrt(node.index))]" stroke="white" stroke-width="1" #mousedown="currentMove = {x: $event.screenX, y: $event.screenY, node: node}"/>
</svg>
</div>
Javascript:
new Vue({
el: '#app',
data: {
graph: {
nodes: d3.range(100).map(i => ({ index: i, x: null, y: null })),
links: d3.range(99).map(i => ({ source: Math.floor(Math.sqrt(i)), target: i + 1 }))
},
width: Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
height: Math.max(document.documentElement.clientHeight, window.innerHeight || 0) - 40,
padding: 20,
colors: ['#2196F3', '#E91E63', '#7E57C2', '#009688', '#00BCD4', '#EF6C00', '#4CAF50', '#FF9800', '#F44336', '#CDDC39', '#9C27B0'],
simulation: null,
currentMove: null
},
computed: {
bounds() {
return {
minX: Math.min(...this.graph.nodes.map(n => n.x)),
maxX: Math.max(...this.graph.nodes.map(n => n.x)),
minY: Math.min(...this.graph.nodes.map(n => n.y)),
maxY: Math.max(...this.graph.nodes.map(n => n.y))
}
},
coords() {
return this.graph.nodes.map(node => {
return {
x: this.padding + (node.x - this.bounds.minX) * (this.width - 2*this.padding) / (this.bounds.maxX - this.bounds.minX),
y: this.padding + (node.y - this.bounds.minY) * (this.height - 2*this.padding) / (this.bounds.maxY - this.bounds.minY)
}
})
}
},
created(){
this.simulation = d3.forceSimulation(this.graph.nodes)
.force('charge', d3.forceManyBody().strength(d => -100))
.force('link', d3.forceLink(this.graph.links))
.force('x', d3.forceX())
.force('y', d3.forceY())
},
methods: {
drag(e) {
if (this.currentMove) {
this.currentMove.node.fx = this.currentMove.node.x - (this.currentMove.x - e.screenX) * (this.bounds.maxX - this.bounds.minX) / (this.width - 2 * this.padding)
this.currentMove.node.fy = this.currentMove.node.y -(this.currentMove.y - e.screenY) * (this.bounds.maxY - this.bounds.minY) / (this.height - 2 * this.padding)
this.currentMove.x = e.screenX
this.currentMove.y = e.screenY
}
},
drop(){
delete this.currentMove.node.fx
delete this.currentMove.node.fy
this.currentMove = null
this.simulation.alpha(1)
this.simulation.restart()
}
}
})
The main drawback I see is if you have a large d3.js codebase you want to reuse in your Vue.js application as you will have to rewrite it. You will also find a lot of examples written in pure d3.js syntax and you will have to adapt them.

Related

Background image not showing on canvas in a strange way

I'm coding a greeting card generator to train in VueJS 3. Everything is working correctly, apart from one thing, look at my code:
<template>
<div>
<h1>greeting card generator</h1>
<div class="board">
<canvas id='myCanvas' :width="size.w" :height="size.h" tabindex='0'
style="border:1px solid #000000;"
></canvas>
</div>
<textarea
:style="'width:' + size.w + 'px; resize:none;'"
v-model="texte"
placeholder="Write your text here">
</textarea>
</div>
</template>
<script>
import {
defineComponent, onMounted, ref, reactive, watch,
} from 'vue';
export default defineComponent({
setup() {
const myCanvas = ref(null);
const texte = ref('');
const rapport = ref(0);
const size = reactive({
w: window.innerWidth * 0.8,
h: (window.innerWidth * 0.8) / 1.8083832335329342,
});
function drawText() {
const fontSize = 0.05 * window.innerWidth - 10;
myCanvas.value.font = `${fontSize}px Adrip`;
myCanvas.value.textAlign = 'center';
const x = size.w / 2;
const lineHeight = fontSize;
const lines = texte.value.split('\n');
for (let i = 0; i < lines.length; i += 1) {
myCanvas.value.fillText(
lines[lines.length - i - 1],
x,
(size.h * 0.98) - (i * lineHeight),
);
}
}
function initCarte() {
const background = new Image();
background.src = '/img/fond.jpeg';
background.onload = function () {
rapport.value = background.naturalWidth / background.naturalHeight;
size.h = size.w / rapport.value;
try {
myCanvas.value.drawImage(background, 0, 0, size.w, size.h);
} catch (e) {
console.log(`ERREUR DE CHARGEMENT D'IMAGE: ${e}`);
}
drawText();
};
}
function handleResize() {
size.w = window.innerWidth * 0.8;
size.h = size.w / rapport.value;
initCarte();
}
window.addEventListener('resize', handleResize);
onMounted(() => {
const c = document.getElementById('myCanvas');
const ctx = c.getContext('2d');
myCanvas.value = ctx;
initCarte();
});
watch(texte, () => {
initCarte();
});
return {
myCanvas,
size,
texte,
};
},
});
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
#font-face {
font-family: 'Adrip';
src: local('Adrip'), url('/fonts/adrip1.ttf') format('truetype');
}
#myCanvas {
border: 1px solid grey;
}
</style>
Look at this line:
h: (window.innerWidth * 0.8) / 1.8083832335329342,
If I don't hardcode this and only put the canonical value window.innerWidth * 0.8, the image doesn't display, although the size.h = size.w / rapport.value; line executes correctly.
I really don't understand this behaviour, could somebody explain it to me?
Also, if anybody has a clue on how it would be possible to load the image once and for all so that I don't have to load it at every refresh, it would be better :)
Thanks in advance!
Your problem is that you change the size of the canvas after drawing the image, due to how templating magic works. If you put debugger behind drawText(); in the background onload function, you will see that it actually draws the image. However, in this same function, you set size.h. size is reactive, and is thus marked as "dirty". size is also used in the template, so the template is marked dirty. After the onload function is executed, Vue will rerender your template... and erase your image.
I think your best bet here is to use nextTick. You need to use it sparingly, but I think this is one of the instances where you have no choice but to wait for the DOM to settle. To do this, import nextTick from vue:
import { nextTick } from 'vue';
Then surround your drawImage try-catch block with that.
background.onload = function () {
rapport.value = background.naturalWidth / background.naturalHeight;
size.h = size.w / rapport.value;
nextTick(() => {
try {
myCanvas.value.drawImage(background, 0, 0, size.w, size.h);
} catch (e) {
console.log(`ERREUR DE CHARGEMENT D'IMAGE: ${e}`);
}
drawText();
});
};
As for your last question how to load the image once... the short answer is... you can't. Whenever the canvas changes, you need to redraw it. At least the image should be cached by the browser, so it just draws it from cache rather than doing another http request.

Draggable view within parent boundaries

I'm facing a task where I want to place a draggable marker on a background image and afterwards get the coordinates of the marker within the background image.
I've followed this neat tutorial to make a draggable marker using Animated.Image, PanResponder and Animated.ValueXY. The problem is that I cannot figure out how to limit the draggable view to only move around within the boundaries of its parent (the background image).
Any help is very much appreciated :)
Best regards
Jens
Here is one way to do it using the react-native-gesture-responder.
import React, { Component } from 'react'
import {
StyleSheet,
Animated,
View,
} from 'react-native'
import { createResponder } from 'react-native-gesture-responder'
const styles = StyleSheet.create({
container: {
height: '100%',
width: '100%',
},
draggable: {
height: 50,
width: 50,
},
})
export default class WorldMap extends Component {
constructor(props) {
super(props)
this.state = {
x: new Animated.Value(0),
y: new Animated.Value(0),
}
}
componentWillMount() {
this.Responder = createResponder({
onStartShouldSetResponder: () => true,
onStartShouldSetResponderCapture: () => true,
onMoveShouldSetResponder: () => true,
onMoveShouldSetResponderCapture: () => true,
onResponderMove: (evt, gestureState) => {
this.pan(gestureState)
},
onPanResponderTerminationRequest: () => true,
})
}
pan = (gestureState) => {
const { x, y } = this.state
const maxX = 250
const minX = 0
const maxY = 250
const minY = 0
const xDiff = gestureState.moveX - gestureState.previousMoveX
const yDiff = gestureState.moveY - gestureState.previousMoveY
let newX = x._value + xDiff
let newY = y._value + yDiff
if (newX < minX) {
newX = minX
} else if (newX > maxX) {
newX = maxX
}
if (newY < minY) {
newY = minY
} else if (newY > maxY) {
newY = maxY
}
x.setValue(newX)
y.setValue(newY)
}
render() {
const {
x, y,
} = this.state
const imageStyle = { left: x, top: y }
return (
<View
style={styles.container}
>
<Animated.Image
source={require('./img.png')}
{...this.Responder}
resizeMode={'contain'}
style={[styles.draggable, imageStyle]}
/>
</View>
)
}
}
I accomplished this another way using only the react-native PanResponder and Animated libraries. It took a number of steps to accomplish and was difficult to figure out based on the docs, however, it is working well on both platforms and seems decently performant.
The first step was to find the height, width, x, and y of the parent element (which in my case was a View). View takes an onLayout prop. onLayout={this.onLayoutContainer}
Here is the function where I get the size of the parent and then setState to the values, so I have it available in the next function.
` onLayoutContainer = async (e) => {
await this.setState({
width: e.nativeEvent.layout.width,
height: e.nativeEvent.layout.height,
x: e.nativeEvent.layout.x,
y: e.nativeEvent.layout.y
})
this.initiateAnimator()
}`
At this point, I had the parent size and position on the screen, so I did some math and initiated a new Animated.ValueXY. I set the x and y of the initial position I wanted my image offset by, and used the known values to center my image in the element.
I continued setting up my panResponder with the appropriate values, however ultimately found that I had to interpolate the x and y values to provide boundaries it could operate in, plus 'clamp' the animation to not go outside of those boundaries. The entire function looks like this:
` initiateAnimator = () => {
this.animatedValue = new Animated.ValueXY({x: this.state.width/2 - 50, y: ((this.state.height + this.state.y ) / 2) - 75 })
this.value = {x: this.state.width/2 - 50, y: ((this.state.height + this.state.y ) / 2) - 75 }
this.animatedValue.addListener((value) => this.value = value)
this.panResponder = PanResponder.create({
onStartShouldSetPanResponder: ( event, gestureState ) => true,
onMoveShouldSetPanResponder: (event, gestureState) => true,
onPanResponderGrant: ( event, gestureState) => {
this.animatedValue.setOffset({
x: this.value.x,
y: this.value.y
})
},
onPanResponderMove: Animated.event([ null, { dx: this.animatedValue.x, dy: this.animatedValue.y}]),
})
boundX = this.animatedValue.x.interpolate({
inputRange: [-10, deviceWidth - 100],
outputRange: [-10, deviceWidth - 100],
extrapolate: 'clamp'
})
boundY = this.animatedValue.y.interpolate({
inputRange: [-10, this.state.height - 90],
outputRange: [-10, this.state.height - 90],
extrapolate: 'clamp'
})
}`
The important variables here are boundX and boundY, as they are the interpolated values that will not go outside of the desired area. I then set up my Animated.Image with these values, which looks like this:
` <Animated.Image
{...this.panResponder.panHandlers}
style={{
transform: [{translateX: boundX}, {translateY: boundY}],
height: 100,
width: 100,
}}
resizeMode='contain'
source={eventEditing.resource.img_path}
/>`
The last thing I had to make sure, was that all of the values were available to the animation before it tried to render, so I put a conditional in my render method to check first for this.state.width, otherwise, render a dummy view in the meantime. All of this together allowed me to accomplish the desired result, but like I said it seems overly verbose/involved to accomplish something that seems so simple - 'stay within my parent.'

CycleJS Collection returning old data

I'm trying to recreate RxMarbles for RxJS 5, but I'm having feedback problems when I change the collection's data (specifically the length of the data source).
I added console.logs for debugging
Note for those who are familiar with RxMarbles, I renamed "Diagram" to "Timeline".
import { svg } from '#cycle/dom';
import isolate from '#cycle/isolate';
import { Observable } from 'rxjs';
import { apply, flip, map, max, merge, path, prop, sortBy, zip } from 'ramda';
import { Collection } from '../collection';
import { Marble } from './marble';
import { EndMarker } from './end-marker';
function sortMarbleDoms$(marbles$) {
const doms$ = Collection.pluck(marbles$, prop('DOM'));
const dataList$ = Collection.pluck(marbles$, prop('data'));
return Observable.combineLatest(doms$, dataList$, zip)
.map(sortBy(path([1, 'time'])))
.map(map(prop(0)));
}
function OriginalTimeline({ DOM, marbles: marblesState$, end: end$ }) {
const marblesProps$ = end$.map(({ time }) => ({
minTime: 0,
maxTime: time,
}));
const endMarkerProps$ = marblesState$.map(marbles => ({
minTime: marbles.map(prop('time')).reduce(max, 0),
maxTime: 100,
}));
const marblesSources = { DOM, props: marblesProps$ };
const endMarkerSources = {
DOM,
props: endMarkerProps$,
time: end$.pluck('time'),
};
const marbles$ = Collection.gather(
Marble, marblesSources, marblesState$
.do(a=>console.log('marblesState', a)), '_itemId');
const marbleDOMs$ = sortMarbleDoms$(marbles$);
const endMarker = EndMarker(endMarkerSources);
const vtree$ = Observable.combineLatest(marbleDOMs$, endMarker.DOM)
.map(([marbleDOMs, endMarkerDOM]) =>
svg({
attrs: { viewBox: '0 0 100 10' },
style: { width: 500, height: 50, overflow: 'visible' },
}, [
svg.line({
attrs: { x1: 0, x2: 100, y1: 5, y2: 5 },
style: { stroke: 'black', strokeWidth: 0.4 },
}),
endMarkerDOM,
...marbleDOMs,
])
);
const marbleData$ = Collection.pluck(marbles$, prop('data'))
.withLatestFrom(marblesState$, zip)
.map(map(apply(flip(merge))))
const data$ = Observable.combineLatest(marbleData$, endMarker.time)
.map(([marbles, endMarkerTime]) => ({
marbles,
end: { time: endMarkerTime },
}))
.debounceTime(1);
return { DOM: vtree$, data: data$.do(a=>console.log('tdata', a)) };
}
export function Timeline(sources) {
return isolate(OriginalTimeline)(sources);
}
The basic structure of the app is that all necessary data is fed into a global sink to a dummy driver that just takes the data and re-emits it as is (so in theory, all outputs should be new inputs).
Because of this, the problem might be in other parts of my code so I'm happy to post a codepen/plunkr of the code if it helps. This is indeed working sometimes, but not all the time.
Here's the console outputs (abridged)
store Object {route: "merge", inputs: undefined}
timeline.js:39 marblesState [Object, Object, Object, Object]
timeline.js:69 tdata Object {marbles: Array[3], end: Object}
sandbox.js:48 data [Object, Object]
app.js:26 store Object {route: "merge", inputs: Array[2]}
Notice the marblesState has 4 objects, but the tdata returns marbles with an array of 3 objects. For some reason, the Collection is only returning 3 items.
Any help is appreciated. Thanks!
I have no idea why this makes sense but moving up the debounceTime(1) made it work
const marbleData$ = Collection.pluck(marbles$, prop('data'))
.debounceTime(1)
.withLatestFrom(marblesState$, zip)
.map(map(apply(flip(merge))))
const data$ = Observable.combineLatest(marbleData$, endMarker.time)
.map(([marbles, endMarkerTime]) => ({
marbles,
end: { time: endMarkerTime },
}));
The Collection.pluck was sending once for each piece of new and old data.

React animate elements in component

I want to animate some elements inside a component without using jQuery or another similar library. I've got the following code running:
export default React.createClass({
getInitialState() {
return {
women: {
total: this.props.women.upvotes + this.props.women.downvotes,
up_height: {
'height': 0
},
down_height: {
'height': 0
}
}
};
},
componentDidMount: function() {
this.setState({
women: {
up_height: {
'height': (this.props.women.upvotes / this.state.women.total) * 100 + '%'
},
down_height: {
'height': (this.props.women.downvotes / this.state.women.total) * 100 + '%'
}
}
})
},
render() {
return (
<li className="block-element">
<div className="upvotes">
<span className="women" style={this.state.women.up_height}>{this.props.women.upvotes}</span>
<span className="men">{this.props.men.upvotes}</span>
</div>
</li>
)
}
})
So on getInitialState the style height is set to 0. When de componentDidMount its set to the height it supposed to be. I did add a transition to the elements trough CSS, but nothing seem to happen.
I've tried adding a setTimeout around the setState in componentDidMount, but this broke the setState. Any heads up? How to make this work?
Another possibility is using requestAnimationFrame, as used in this (duplicate?) question.
If you do not mind using a library implementing this functionality specifically for React, react.animate (not to confuse with react-animate) does the thing. The current version (date: 9.4.16) on npm seems not to support CommonJS, but the version on github is more up to date. As an example:
npm install react react-dom http://github.com/pleasetrythisathome/react.animate/tarball/master
test.js :
React = require('react')
ReactDOM = require('react-dom')
require('#pleasetrythisathome/react.animate')
SomeComponent = React.createClass({
mixins: [React.Animate],
getInitialState: function () {return {bananas: 0}},
componentDidMount: function () {
this.animate({bananas: 100}, 1000, () => {console.log('Finish')})
},
render: function () {return React.DOM.span({}, this.state.bananas)},
})
ReactDOM.render(React.createElement(SomeComponent), document.getElementById('apple'))
then e.g. with browserify
browserify test.js > apple.js
test.html :
<div id="apple"></div>
<script src="apple.js"></script>
JSFiddle:
https://jsfiddle.net/jo5tp5mt/

React component with Dagre-D3 not drawing correctly

I converted this Dagre-D3 demo to a React component. The code is below.
import React from 'react'
import d3 from 'd3'
import dagreD3 from 'dagre-d3'
export default class D3Chart extends React.Component {
constructor () {
super();
}
componentDidMount() {
// Create the input graph
var g = new dagreD3.graphlib.Graph()
.setGraph({})
.setDefaultEdgeLabel(function() { return {}; });
// Here we"re setting nodeclass, which is used by our custom drawNodes function
// below.
g.setNode(0, { label: "TOP", class: "type-TOP" });
g.setNode(1, { label: "S", class: "type-S" });
g.setNode(2, { label: "NP", class: "type-NP" });
g.setNode(3, { label: "DT", class: "type-DT" });
g.setNode(4, { label: "This", class: "type-TK" });
g.setNode(5, { label: "VP", class: "type-VP" });
g.setNode(6, { label: "VBZ", class: "type-VBZ" });
g.setNode(7, { label: "is", class: "type-TK" });
g.setNode(8, { label: "NP", class: "type-NP" });
g.setNode(9, { label: "DT", class: "type-DT" });
g.setNode(10, { label: "an", class: "type-TK" });
g.setNode(11, { label: "NN", class: "type-NN" });
g.setNode(12, { label: "example", class: "type-TK" });
g.setNode(13, { label: ".", class: "type-." });
g.setNode(14, { label: "sentence", class: "type-TK" });
g.nodes().forEach(function(v) {
var node = g.node(v);
// Round the corners of the nodes
node.rx = node.ry = 5;
});
// Set up edges, no special attributes.
g.setEdge(3, 4);
g.setEdge(2, 3);
g.setEdge(1, 2);
g.setEdge(6, 7);
g.setEdge(5, 6);
g.setEdge(9, 10);
g.setEdge(8, 9);
g.setEdge(11,12);
g.setEdge(8, 11);
g.setEdge(5, 8);
g.setEdge(1, 5);
g.setEdge(13,14);
g.setEdge(1, 13);
g.setEdge(0, 1)
// Create the renderer
var render = new dagreD3.render();
// Set up an SVG group so that we can translate the final graph.
var svg = d3.select(React.findDOMNode(this.refs.nodeTree));
var svgGroup = d3.select(React.findDOMNode(this.refs.nodeTreeGroup));
// Run the renderer. This is what draws the final graph.
render(d3.select(React.findDOMNode(this.refs.nodeTreeGroup)), g);
// Center the graph
var xCenterOffset = (svg.attr("width") - g.graph().width) / 2;
svgGroup.attr("transform", "translate(" + xCenterOffset + ", 20)");
svg.attr("height", g.graph().height + 40);
}
render() {
return (<svg id="nodeTree" ref="nodeTree" width="960" height="600"><g ref="nodeTreeGroup"/></svg>
)
};
}
The problem is that the rendering of the nodes are mis-aligned and their sizes too.
This is how it looks like. How it should like is here.
UPDATE:
This is how the first node looks like:
What now:
<g class="node type-TOP" transform="translate(100,0)" style="opacity: 1;"><rect rx="5" ry="5" x="-10" y="-10" width="20" height="20"></rect><g class="label" transform="translate(0,0)"><g transform="translate(0,0)"><text><tspan xml:space="preserve" dy="1em" x="1">TOP</tspan></text></g></g></g>
What should be:
<g class="node type-TOP" transform="translate(211.25,18)" style="opacity: 1;"><rect rx="5" ry="5" x="-24.5" y="-18" width="49" height="36"></rect><g class="label" transform="translate(0,0)"><g transform="translate(-14.5,-8)"><text><tspan xml:space="preserve" dy="1em" x="1">TOP</tspan></text></g></g></g>
The width and height are not calculated correctly. The width should be 49 but it is only 20.
Try to set the height and width inside the componentDidMount just after you caught the svg with findDOMNode.
Or try to put the height and width this way.
style={{height:'900', width:'300'}}
Let me know if it works
I've figured out a workaround for this. I am having the exact same issue as you, and through pure trial and error I discovered that if you append the graph to the body of the page, it works fine. The text and nodes line up and the node sizes change appropriately to the text. This isn't ideal, but is a workaround..
(I don't know react syntax, so bear with me)
Instead of having a svg node pre created like this (don't do this):
var svg = d3.select(React.findDOMNode(this.refs.nodeTree));
Build one on the fly and append it to the body element of your whole HTML doc (do this):
var svg = d3.select(React.findDOMNode('body'))
.append('svg')
.attr('width', 500)
.attr('height', 500);
I'm not sure why this is, possibly the reason you explained in the comments of François Richard's answer.

Resources