Olical / react-faux-dom

DOM like structure that renders to React (unmaintained, archived)
http://oli.me.uk/2015/09/09/d3-within-react-the-right-way/
The Unlicense
1.21k stars 87 forks source link

Cannot append 'svg' properly with 'ReactFauxDOM' #126

Closed jeff1evesque closed 6 years ago

jeff1evesque commented 6 years ago

I have the following ReactFauxDom implementation:

import React, { Component } from 'react';
import * as d3 from 'd3';
import ReactFauxDOM from 'react-faux-dom';

class Animate extends Component {
    render() {
        const w = window.innerWidth;
        const h = window.innerHeight;
        var fauxRoot = ReactFauxDOM.createElement('div');

        let nodes = d3.range(200).map(function() {
            return {r: Math.random() * 12 + 4};
        });

        let root = nodes[0];
        let color = d3.scaleOrdinal().range(d3.schemeCategory10);

        root.radius = 0;
        root.fixed = true;

        const forceX = d3.forceX(w / 2).strength(0.015);
        const forceY = d3.forceY(h / 2).strength(0.015);

        let force = d3.forceSimulation()
            .velocityDecay(0.2)
            .force('x', forceX)
            .force('y', forceY)
            .force('collide', d3.forceCollide().radius(function(d){
                if (d === root) {
                    return Math.random() * 50 + 100;
                }
                return d.r + 2;
            }).iterations(5))
            .nodes(nodes).on('tick', ticked);

        let svg = d3.select(fauxRoot).append('svg')
            .attr('width', w)
            .attr('height', h);

        svg.selectAll('circle')
            .data(nodes.slice(1))
            .enter().append('circle')
            .attr('r', function(d) { return d.r; })
            .style('fill', function(d, i) { return color(i % 3); });

        function ticked(e) {
            svg.selectAll('circle')
                .attr('cx', function(d) { return d.x; })
                .attr('cy', function(d) { return d.y; });
        };

        svg.on('mousemove', function() {
            const p1 = d3.mouse(this);
            root.fx = p1[0];
            root.fy = p1[1];
            force.alphaTarget(0.3).restart();//reheat the simulation
        });

        return fauxRoot.toReact();
    }
}

// indicate which class can be exported, and instantiated via 'require'
export default Animate;

It renders as follows:

stacked

But, when I change the above's d3.select(fauxRoot).append('svg') to d3.select('body').append('svg'), it incorrectly renders, duplicated svg's, at the bottom of the <body>:

duplicated

The following is what I'm trying to get it to look like:

bubbles

Note: the original source code was written with d3js (version 3). It's update doesn't have a nice effect, that the gravity callback provided. More specifically, when the mouse approaches the aggregated circles, they should collectively get repelled. This is less so, with the adjusted jsfiddle's version 4 update.

jeff1evesque commented 6 years ago

I adjusted my code to use animateFauxDom:

import React from 'react'
import * as d3 from 'd3'
import {withFauxDOM} from 'react-faux-dom'

class MyReactComponent extends React.Component {
    componentDidMount() {
        const w = window.innerWidth;
        const h = window.innerHeight;
        const faux = this.props.connectFauxDOM('svg', 'collision');

        let nodes = d3.range(200).map(function () {
          return { r: Math.random() * 12 + 4 };
        });

        let root = nodes[0];
        let color = d3.scaleOrdinal().range(d3.schemeCategory10);

        root.radius = 0;
        root.fixed = true;

        const forceX = d3.forceX(w / 2).strength(0.015);
        const forceY = d3.forceY(h / 2).strength(0.015);

        var svg = d3.select(faux)
          .attr('width', w)
          .attr('height', h)
          .append('g');

        svg.selectAll('circle')
            .data(nodes.slice(1))
            .enter()
            .append('circle')
            .attr('r', function (d) { return d.r; })
            .style('fill', function (d, i) { return color(i % 3); });

        function ticked(e) {
            svg.selectAll('circle')
            .attr('cx', function (d) { return d.x; })
            .attr('cy', function (d) { return d.y; });
        };

        let force = d3.forceSimulation()
            .velocityDecay(0.2)
            .force('x', forceX)
            .force('y', forceY)
            .force('collide', d3.forceCollide().radius(function (d) {
                if (d === root) {
                    return Math.random() * 50 + 100;
                }
                return d.r + 2;
            }).iterations(5))
            .nodes(nodes).on('tick', ticked);

        svg.on('mousemove', function () {
            root.fx = d3.event.pageX;
            root.fy = d3.event.pageY;
            force.alphaTarget(0.3).restart();//reheat the simulation
            this.props.animateFauxDOM(3500);
        });
        this.props.animateFauxDOM(3500);
    }

    render() {
        return (
            <div>{this.props.collision}</div>
        )
    }
}

MyReactComponent.defaultProps = {
  collision: 'loading'
}

export default withFauxDOM(MyReactComponent)

However, it seems to only last for a few seconds before displaying an Uncaught TypeError:

type-error

jeff1evesque commented 6 years ago

Got the animation to work without implementing react-faux-dom:

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import * as d3 from 'd3';

class AnimateCollisions extends React.Component {
    constructor() {
        super();
        const nodes = this.generateNodes(200);
        this.state = {
            colors: d3.scaleOrdinal().range(d3.schemeCategory10),
            nodes: nodes,
            root: nodes[0],
            width: window.innerWidth,
            height: window.innerHeight,
            alpha_target: .4,
            iterations: 4,
            velocity_decay: .1,
            forceX: d3.forceX(window.innerWidth / 2).strength(0.015),
            forceY: d3.forceY(window.innerHeight / 2).strength(0.015),
        }
        this.getColor = this.getColor.bind(this);
        this.generateNodes = this.generateNodes.bind(this);
        this.storeForce = this.storeForce.bind(this);
        this.renderD3 = this.renderD3.bind(this);
    }

    componentDidMount() {
        this.renderD3();
    }

    generateNodes(range_limit) {
        return [...Array(range_limit).keys()].map(function() {
            return { r: Math.random() * 12 + 4 };
        });
    }

    storeForce(force) {
        this.setState({force: force});
    }

    getColor(i) {
        return this.state.colors(i % 3);
    }

    renderD3() {
        const nodes = this.state.nodes;
        const forceX = this.state.forceX;
        const forceY = this.state.forceY;
        const root = nodes[0];
        const svg = d3.select(ReactDOM.findDOMNode(this.refs.animation));
        const alpha = this.state.alpha_target;
        const iterations = this.state.iterations;

        root.radius = 0;
        root.fixed = true;

        svg.selectAll('circle')
            .data(nodes.slice(1))
            .enter();

        function ticked(e) {
            svg.selectAll('circle')
                .attr('cx', function(d) { return d.x; })
                .attr('cy', function(d) { return d.y; });
        };

        svg.on('mousemove', function() {
            const p1 = d3.mouse(this);
            root.fx = p1[0];
            root.fy = p1[1];
            force.alphaTarget(alpha).restart();//reheat the simulation
        });

        let force = d3.forceSimulation()
            .velocityDecay(this.state.velocity_decay)
            .force('x', forceX)
            .force('y', forceY)
            .force('collide', d3.forceCollide().radius(function(d) {
                if (d === root) {
                    return Math.random() * 50 + 100;
                }
                return d.r + 2;
            }).iterations(iterations))
            .nodes(nodes).on('tick', ticked);

        this.storeForce(force);
    }

    render() {
        // use React to draw all the nodes, d3 calculates the x and y
        const nodes = this.state.nodes.slice(1).map((node, index) => {
            const color = this.getColor(index);
            return (
                <circle
                    fill={color}
                    cx={node.x}
                    cy={node.y}
                    r={node.r}
                    key={`circle-${index}`}
                />
            );
        });

        return (
            <svg width={this.state.width} height={this.state.height}>
                <g ref='animation'>{nodes}</g>
            </svg>
        )
    }
}

export default AnimateCollisions;

This is my first D3v4 attempt. If anyone has advice on how to make my animation better, or smoother, please let me know:

Olical commented 6 years ago

I'm glad you got it working and I recommend just using plain D3 if you want to use v4 + complex animations. I'm not sure what was wrong though, it should render fine. Whenever you start using animation or physics you'll run into issues because D3 mutates the actual DOM pretty heavily. This is better for simple visualisations. Okay to close this now?

jeff1evesque commented 6 years ago

Got it. Sure, can close. Have one question, if you don't mind, and able to answer. My animation (first D3 attempt) has some kind of memory leak in react. When I toggle between the homepage, where the animation resides, and another page (i.e. /login), via the main menu, and do this many times, the animation begins to slow down noticeably. Is there some kind of approach, to reclaim some kind of event when deconstructing the component, perhaps in the ComponentWillUnmount? Sorry, for the question. Been on IRC, and since this is my first D3 attempt, haven't had much luck.

Olical commented 6 years ago

Oh wow, that's nasty. As far as I know, D3 doesn't hold references, everything is stored on the DOM itself. If the GC isn't picking up your DOM nodes I'd imagine it's because you're holding onto a reference somewhere?

jeff1evesque commented 6 years ago

I noticed if I comment out force.alphaTarget(alpha).restart();, the animation doesn't degrade, between toggling of multiple pages, and back to the homepage where the animation resides. However, the collision aspect of the animation, with the mouseover, no longer works. But, the beginning part of the animation no longer shows degregation, no matter how many times I toggle between 2+ react pages.

Olical commented 6 years ago

I don't think I'll be able to help you here, that sounds like a fairly intricate D3 issue. First rule out React, try this on a standalone page and flick it in and out of existence. Then if that works fine you know it's how you're creating / destroying the elements with React.

I see you're using the constructor in the class? Maybe you want to use "on mount" instead? There are posts on how to embed D3 in React like this, it might be worth checking those for any traps you're falling into that are well known.

jeff1evesque commented 6 years ago

Had to implement this.state.forceSimulation.stop(), within componentWillUnmount. Should have taken a closer look into d3's documentation earlier this week.