<div id="app"></div>

<!--

3D animated chart: React, WebGL, CSS3D, mouse interactivity (try hovering over the bars!)

Libraries and sources:

- React
- ReGL (WebGL helper): http://regl.party/
- glMatrix (math): http://glmatrix.net/
- onecolor (RGB conversion): https://github.com/One-com/one-color
- ColourLovers palettes extracted by @mattdesl (https://twitter.com/mattdesl): https://github.com/Jam3/nice-color-palettes
- React Motion (animation): https://github.com/chenglou/react-motion
- Mockaroo (chart labels): http://www.mockaroo.com/
- Google Fonts Michroma

-->
html, body, #app {
    width: 100%;
    height: 100%;
}

.main {
    display: flex;
    flex-direction: column;
    box-sizing: border-box;
    min-height: 100%;
    padding: 0 0 20px;
    justify-content: center;
    align-items: center;
    margin-top: -20px;

    > ._chart {
        flex: none;
        position: relative;
        width: 480px;
        height: 360px;
    }
    
    > button {
        flex: none;
        display: block;
        border: 1px solid #fff;
        width: 200px;
        height: 40px;
        padding: 0 0 4px;
        line-height: 36px;
        font-family: Michroma;
        font-size: 18px;
        color: #fff;
        background: none;
        border-radius: 5px;
        cursor: pointer;
        outline: none;
        text-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
    }
}

.random-chart__hover-label {
    position: absolute;
    background: rgba(255, 255, 255, 0.9);
    color: #444;
    padding: 5px 5px 7px; // vertical alignment nudge
    border-radius: 5px;
    font-family: Michroma, Arial, sans-serif;
    font-size: 16px;
    transform: translate(-50%, -100%) translate(0, -8px);

    > ._arrow {
        position: absolute;
        top: 100%;
        left: 50%;
        margin-left: -8px;
        border-left: 8px solid transparent;
        border-right: 8px solid transparent;
        border-top: 8px solid rgba(255, 255, 255, 0.9);
    }
}
View Compiled
const onecolor = one.color;
const { Motion, spring } = ReactMotion;
const reglInit = createREGL;

function hex2vector(cssHex) {
    const pc = onecolor(cssHex);

    return vec3.fromValues(
        pc.red(),
        pc.green(),
        pc.blue()
    );
}

class BarChart3D extends React.PureComponent {
    constructor({
        values,
        width,
        height,
        palette
    }) {
        super();

        // copy value array and coerce to 0..1
        this._values = [].concat(values).map(
            value => Math.max(0, Math.min(1, value)) || 0
        );

        if (this._values.length < 1) {
            throw new Error('missing values');
        }

        this.state = {
            barIsActive: this._values.map(() => false),
            graphicsInitialized: false
        };

        this._width = width;
        this._height = height;

        this._chartAreaW = 500;
        this._chartAreaH = 300;
        this._barSpacing = 10;
        this._barExtraRadius = this._barSpacing * 0.3;
        this._patternSize = 50;

        this._regl = null; // initialized after first render

        // reusable computation elements
        this._barBaseVec2 = vec2.create();
        this._barTopVec3 = vec3.create();
    }

    _setBarIsActive(index, status) {
        // reduce bar status state into new instance
        this.setState(state => ({
            barIsActive: [].concat(
                state.barIsActive.slice(0, index),
                [ !!status ],
                state.barIsActive.slice(index + 1)
            )
        }));
    }

    _handleCanvasRef = (canvas) => {
        // when unlinking, help WebGL context get cleaned up
        if (!canvas) {
            this._regl.destroy();
            this._regl = null; // dereference just in case
            return;
        }

        // initialize graphics
        this._regl = reglInit({
            canvas: canvas
        });

        this._barCommand = this._regl({
            vert: `
                precision mediump float;

                uniform mat4 camera;
                uniform vec2 base;
                uniform float radius, height;

                attribute vec3 position;
                attribute vec3 normal;

                varying vec3 fragPosition;
                varying vec3 fragNormal;

                void main() {
                    float z = position.z * height;

                    fragPosition = vec3(
                        (position.xy + vec2(1.0, 1.0)) * radius,
                        z
                    );
                    fragNormal = normal;

                    gl_Position = camera * vec4(
                        base + position.xy * radius,
                        z,
                        1.0
                    );
                }
            `,

            frag: `
                precision mediump float;

                uniform vec3 baseColor, secondaryColor, highlightColor;
                uniform float height;
                uniform float highlight;
                uniform int patternIndex;
                uniform float patternSize;

                varying vec3 fragPosition;
                varying vec3 fragNormal;

                float stripePattern() {
                    return step(patternSize * 0.5, mod((
                        fragPosition.y
                        - fragPosition.x
                        + (height - fragPosition.z)
                    ), patternSize));
                }

                float stripe2Pattern() {
                    return step(patternSize * 0.5, mod((
                        fragPosition.x
                        - fragPosition.y
                        + (height - fragPosition.z)
                    ), patternSize));
                }

                float checkerPattern() {
                    vec3 cellPosition = vec3(0, 0, height) - fragPosition;
                    float cellSize = patternSize * 0.4;

                    vec3 cellIndex = cellPosition / cellSize;
                    float dotChoice = mod((
                        step(1.0, mod(cellIndex.x, 2.0))
                        + step(1.0, mod(cellIndex.y, 2.0))
                        + step(1.0, mod(cellIndex.z, 2.0))
                    ), 2.0);

                    return dotChoice;
                }

                float dotPattern() {
                    vec3 attachedPos = vec3(0, 0, height) - fragPosition;

                    float dotSize = patternSize * 0.3;
                    vec3 dotPosition = attachedPos + dotSize * 0.5;
                    float dotDistance = length(mod(dotPosition, dotSize) / dotSize - vec3(0.5));

                    vec3 dotIndex = dotPosition / dotSize;
                    float dotChoice = mod((
                        step(1.0, mod(dotIndex.x, 2.0))
                        + step(1.0, mod(dotIndex.y, 2.0))
                        + step(1.0, mod(dotIndex.z, 2.0))
                    ), 2.0);

                    return dotChoice * step(dotDistance, 0.5);
                }

                float pattern() {
                    if (patternIndex == 0) {
                        return stripePattern();
                    }

                    if (patternIndex == 1) {
                        return checkerPattern();
                    }

                    if (patternIndex == 2) {
                        return stripe2Pattern();
                    }

                    if (patternIndex == 3) {
                        return dotPattern();
                    }

                    return 0.0;
                }

                void main() {
                    vec3 pigmentColor = mix(baseColor, secondaryColor, pattern());

                    vec3 lightDir = vec3(-0.5, 0.5, 1.0); // non-normalized to ensure top is at 1
                    float light = max(0.0, dot(fragNormal, lightDir));

                    float highlightMix = 1.75 * max(0.0, min(0.5, highlight - 0.25)); // clip off bouncy edges of value range

                    gl_FragColor = vec4(mix(pigmentColor, highlightColor, highlightMix + (1.0 - highlightMix) * light), 1.0);
                }
            `,

            attributes: {
                position: this._regl.buffer([
                    [ -1, 1, 0 ], [ -1, -1, 0 ], [ -1, 1, 1 ], [ -1, -1, 1 ], // left face
                    [ -1, -1, 1 ], [ -1, -1, 0 ], // degen connector
                    [ -1, -1, 0 ], [ 1, -1, 0 ], [ -1, -1, 1 ], [ 1, -1, 1 ], // front face
                    [ 1, -1, 1 ], [ -1, -1, 1 ], // degen connector
                    [ -1, -1, 1 ], [ 1, -1, 1 ], [ -1, 1, 1 ], [ 1, 1, 1 ] // top face
                ]),

                normal: this._regl.buffer([
                    [ -1, 0, 0 ], [ -1, 0, 0 ], [ -1, 0, 0 ], [ -1, 0, 0 ], // left face
                    [ -1, 0, 0 ], [ 0, -1, 0 ], // degen connector
                    [ 0, -1, 0 ], [ 0, -1, 0 ], [ 0, -1, 0 ], [ 0, -1, 0 ], // front face
                    [ 0, -1, 0 ], [ 0, 0, 1 ], // degen connector
                    [ 0, 0, 1 ], [ 0, 0, 1 ], [ 0, 0, 1 ], [ 0, 0, 1 ] // top face
                ])
            },

            uniforms: {
                camera: this._regl.prop('camera'),
                base: this._regl.prop('base'),
                radius: this._regl.prop('radius'),
                height: this._regl.prop('height'),
                highlight: this._regl.prop('highlight'),
                patternIndex: this._regl.prop('patternIndex'),
                patternSize: this._regl.prop('patternSize'),
                baseColor: this._regl.prop('baseColor'),
                secondaryColor: this._regl.prop('secondaryColor'),
                highlightColor: this._regl.prop('highlightColor')
            },

            primitive: 'triangle strip',
            count: 4 + 2 + 4 + 2 + 4
        });

        this.setState({ graphicsInitialized: true });
    }

    // eslint-disable-next-line max-statements
    render() {
        const baseColor = hex2vector(this.props.baseColor);
        const secondaryColor = hex2vector(this.props.secondaryColor);
        const highlightColor = hex2vector(this.props.highlightColor);
        const labelColorCss = this.props.labelColor;

        // chart 3D layout
        const barCellSize = this._chartAreaW / this._values.length;
        const barRadius = Math.max(this._barSpacing / 2, barCellSize / 2 - this._barSpacing); // padding of 10px
        const startX = -barCellSize * (this._values.length - 1) / 2;

        // animation setup (as single instance to help render scene in one shot)
        const motionDefaultStyle = {};
        const motionStyle = {};

        this._values.forEach((value, index) => {
            const isActive = this.state.barIsActive[index];

            motionDefaultStyle[`v${index}`] = 0;
            motionStyle[`v${index}`] = spring(value, { stiffness: 320, damping: 12 });

            motionDefaultStyle[`r${index}`] = 0;
            motionStyle[`r${index}`] = spring(
                isActive ? this._barExtraRadius : 0, // @todo just animate in 0..1 range
                { stiffness: 600, damping: 18 }
            );
        });

        return <Chart3DScene
            viewportWidth={this._width}
            viewportHeight={this._height}
            distance={this._chartAreaH * 4}
            centerX={0}
            centerY={0}
            centerZ={this._chartAreaH / 2}
            canvasRef={this._handleCanvasRef}
            content3d={{
                [`translate3d(${-this._chartAreaW / 2}px, -40px, ${this._chartAreaH}px) rotateX(90deg)`]: (
                    this._values.map((value, index) => <div
                        key={index}
                        style={{
                            position: 'absolute',
                            top: 0,
                            left: 0,
                            width: '100px', // non-fractional size for better precision via scaling
                            height: this._chartAreaH + 'px',

                            // prevent from showing on mobile tap hover
                            // @todo reconsider for a11y
                            WebkitTapHighlightColor: 'rgba(0, 0, 0, 0)',

                            transformOrigin: '0 0',
                            transform: `translate(${index * barCellSize}px, 0px) scale(${barCellSize / 100}, 1)`
                        }}
                        onMouseEnter={() => { this._setBarIsActive(index, true); }}
                        onMouseLeave={() => { this._setBarIsActive(index, false); }}
                        onClick={() => {
                            if (this.props.onBarClick) {
                                this.props.onBarClick(index);
                            }
                        }}
                    />)
                ),
                // @todo carry out contents
                [`translate(${-this._chartAreaW / 2 + 10}px, -60px)`]: (
                    <div style={{
                        whiteSpace: 'nowrap',

                        fontFamily: 'Michroma, Arial, sans-serif',
                        fontSize: '32px',
                        lineHeight: 1,
                        letterSpacing: '-2px',
                        color: labelColorCss
                    }}>{this.props.xLabel}</div>
                ),

                // @todo carry out contents
                [`translate(${this._chartAreaW / 2 + 10}px, -40px) rotateX(90deg) rotateZ(90deg)`]: (
                    <div style={{
                        whiteSpace: 'nowrap',

                        fontFamily: 'Michroma, Arial, sans-serif',
                        fontSize: '40px',
                        lineHeight: 1,
                        letterSpacing: '-2px',
                        color: labelColorCss
                    }}>{this.props.yLabel}</div>
                )
            }}
        >{(cameraMat4) => <div style={{
            position: 'absolute',
            top: 0,
            left: 0,
            width: '100%',
            height: '100%',
            pointerEvents: 'none' // allow underlying hover areas to work as intended
        }}>
            {/* animate when ready to render */}
            {this.state.graphicsInitialized ? <Motion
                defaultStyle={motionDefaultStyle}
                style={motionStyle}
            >{motion => {
                // general rendering refresh
                this._regl.poll();

                // clear canvas
                // @todo set colour?
                this._regl.clear({
                    depth: 1
                });

                // chart bar display
                this._values.forEach((value, index) => {
                    const motionValue = motion[`v${index}`];
                    const motionExtraRadius = motion[`r${index}`];

                    vec2.set(this._barBaseVec2, (index * barCellSize) + startX, barRadius - 40);

                    // @todo sort out how the ReGL framebuffer clearing works with react-motion framerate
                    this._barCommand({
                        camera: cameraMat4,
                        base: this._barBaseVec2,
                        radius: barRadius + motionExtraRadius,
                        height: this._chartAreaH * motionValue,
                        highlight: motionExtraRadius / this._barExtraRadius,
                        patternIndex: index % 4,
                        patternSize: this._patternSize,
                        baseColor: baseColor,
                        secondaryColor: secondaryColor,
                        highlightColor: highlightColor
                    });
                });

                // manually flush
                this._regl._gl.flush();

                // no element actually displayed
                return null;
            }}</Motion> : null}

            {this._values.map((value, index) => {
                // position overlay content on bar top
                vec3.set(
                    this._barTopVec3,
                    (index * barCellSize) + startX,
                    barRadius - 40,
                    value * this._chartAreaH
                );

                vec3.transformMat4(this._barTopVec3, this._barTopVec3, cameraMat4);

                // convert from GL device space (-1 .. 1) to 2D CSS space
                const x = (0.5 + 0.5 * this._barTopVec3[0]) * 100;
                const y = (0.5 - 0.5 * this._barTopVec3[1]) * 100;

                const barContent = this.props.renderBar && this.props.renderBar(
                    index,
                    this.state.barIsActive[index]
                );

                // set up mouse listeners on overlay content to ensure hover continuity
                return <div
                    key={index}
                    style={{
                        position: 'absolute',
                        top: `${y}%`,
                        left: `${x}%`,
                        pointerEvents: 'auto' // restore interactivity
                    }}
                    onMouseEnter={() => { this._setBarIsActive(index, true); }}
                    onMouseLeave={() => { this._setBarIsActive(index, false); }}
                >{barContent || null}</div>;
            })}
        </div>}</Chart3DScene>;
    }
}

class Chart3DScene extends React.PureComponent {
    constructor() {
        super();

        // reusable computation elements
        this._cameraMat4 = mat4.create();
        this._cameraPositionVec3 = vec3.create();
    }

    render() {
        mat4.perspective(this._cameraMat4, 0.5, this.props.viewportWidth / this.props.viewportHeight, 1, this.props.distance * 2.5);

        // camera distance
        vec3.set(this._cameraPositionVec3, 0, 0, -this.props.distance);
        mat4.translate(this._cameraMat4, this._cameraMat4, this._cameraPositionVec3);

        // camera orbit pitch and yaw
        mat4.rotateX(this._cameraMat4, this._cameraMat4, -1.0);
        mat4.rotateZ(this._cameraMat4, this._cameraMat4, Math.PI / 6);

        // camera offset
        vec3.set(this._cameraPositionVec3, -this.props.centerX, -this.props.centerY, -this.props.centerZ);
        mat4.translate(this._cameraMat4, this._cameraMat4, this._cameraPositionVec3);

        const cameraCssMat = `matrix3d(${this._cameraMat4.join(', ')})`;

        // not clipping contents on root div to allow custom overlay content to spill out
        return <div
            style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: '100%'
            }}
        >
            <canvas
                style={{
                    position: 'absolute',
                    top: 0,
                    left: 0,
                    width: '100%',
                    height: '100%'
                }}
                width={this.props.viewportWidth}
                height={this.props.viewportHeight}
                ref={this.props.canvasRef}
            />

            <div style={{
                position: 'absolute',
                zIndex: 0, // reset stacking context
                top: 0,
                left: 0,

                // apply camera matrix, center transform and emulate WebGL device coord range (-1, 1)
                transformStyle: 'preserve-3d'
            }} ref={(node) => {
                if (node) {
                    const realViewWidth = node.parentElement.offsetWidth;
                    const realViewHeight = node.parentElement.offsetHeight;

                    node.style.transform = `
                        translate(${realViewWidth / 2}px, ${realViewHeight / 2}px)
                        scale(${realViewWidth / 2}, ${-realViewHeight / 2})
                        ${cameraCssMat}
                    `;
                }
            }}>
                {Object.keys(this.props.content3d).map(modelTransform => <div
                    key={modelTransform}
                    style={{
                        position: 'absolute',
                        top: 0,
                        left: 0,

                        // transform in the XY plane, flipping first
                        transformStyle: 'preserve-3d',
                        transformOrigin: '0 0',
                        transform: `${modelTransform} scale(1, -1)`
                    }}
                >{this.props.content3d[modelTransform]}</div>)}
            </div>

            {/* custom overlay content */}
            {this.props.children(this._cameraMat4, cameraCssMat)}
        </div>;
    }
}

// from npmjs/nice-color-palettes
const colorPalettes = [
    ["#69d2e7","#a7dbd8","#e0e4cc","#f38630","#fa6900"],["#fe4365","#fc9d9a","#f9cdad","#c8c8a9","#83af9b"],["#ecd078","#d95b43","#c02942","#542437","#53777a"],["#556270","#4ecdc4","#c7f464","#ff6b6b","#c44d58"],["#774f38","#e08e79","#f1d4af","#ece5ce","#c5e0dc"],
    ["#e8ddcb","#cdb380","#036564","#033649","#031634"],["#490a3d","#bd1550","#e97f02","#f8ca00","#8a9b0f"],["#594f4f","#547980","#45ada8","#9de0ad","#e5fcc2"],["#00a0b0","#6a4a3c","#cc333f","#eb6841","#edc951"],["#e94e77","#d68189","#c6a49a","#c6e5d9","#f4ead5"]
]

const mockStockList = [
    'Trading YTD',
    'Last Session',
    'After Hours'
];

const mockSalesList = [
    'Q1 Prev Year',
    'Q3 This Year',
    'YoY Change',
    'Historical'
];

const mockRequestMetricList = [
    'Response',
    'IO Wait',
    'Peak Lag'
];

// credit: https://gist.github.com/blixt/f17b47c62508be59987b
function randomize(seed) {
    const intSeed = seed % 2147483647;
    const safeSeed = intSeed > 0 ? intSeed : intSeed + 2147483646;
    return safeSeed * 16807 % 2147483647;
}

function getRandomizedFraction(seed) {
    return (seed - 1) / 2147483646;
}

class RandomChart extends React.PureComponent {
    constructor(props) {
        super();

        // seeded random generation for predictable results
        const startSeed = Math.floor(Math.random() * 1000000);
        const random1 = randomize(startSeed);
        const random2 = randomize(random1);
        const random3 = randomize(random2);
        const random4 = randomize(random3);

        const mode = getRandomizedFraction(random1);
        const textSelector = getRandomizedFraction(random2);
        const paletteSelector = getRandomizedFraction(random3);
        const seriesLengthSelector = getRandomizedFraction(random4);

        this._textInfo = null;

        this._idNumber = random2 % 100000;

        if (mode < 0.2) {
            this._textInfo = {
                xLabel: 'STOCK: ' + mockStockList[Math.floor(textSelector * mockStockList.length)],
                yLabel: 'PRICE'
            };
        } else if (mode < 0.6) {
            this._textInfo = {
                xLabel: 'SALES: ' + mockSalesList[Math.floor(textSelector * mockSalesList.length)],
                yLabel: 'VOLUME'
            };
        } else {
            this._textInfo = {
                xLabel: 'REQUEST: ' + mockRequestMetricList[Math.floor(textSelector * mockRequestMetricList.length)],
                yLabel: 'TIME (ms)'
            };
        }

        // start with default palette at first, then randomize
        const paletteIndex = Math.floor(paletteSelector * colorPalettes.length);
        this._palette = colorPalettes[paletteIndex];

        this._series = Array(...new Array(3 + Math.floor(seriesLengthSelector * 10))).reduce(
            (itemSeedList) => {
                const prevSeed = itemSeedList.length > 0 ? itemSeedList[itemSeedList.length - 1] : random4;
                return itemSeedList.concat([ randomize(prevSeed) ]);
            },
            []
        ).map(itemSeed => getRandomizedFraction(itemSeed));
    }

    render() {
        // update body background
        document.body.style.background = this._palette[0];

        return (
            <BarChart3D
                values={this._series}
                width={480}
                height={360}
                xLabel={this._textInfo.xLabel}
                yLabel={this._textInfo.yLabel}
                baseColor={this._palette[3]}
                secondaryColor={this._palette[4]}
                highlightColor={this._palette[2]}
                labelColor={this._palette[1]}
                renderBar={(index, isActive) => isActive
                    ? <span className="random-chart__hover-label">
                        <span className="_arrow" />

                        {'0.' + Math.floor(100 + this._series[index] * 100).toString().slice(-2)}
                    </span>
                    : null
                }
            />
        );
    }
}


class Main extends React.PureComponent {
    constructor() {
        super();

        this.state = {
            count: 0
        };
    }
    
    render() {
        return <div className="main">
            <div className="_chart"><RandomChart key={this.state.count} /></div>
            <button
                type="button"
                onClick={() => this.setState(state => (
                    { count: state.count + 1 }
                ))}
            >Generate</button>
        </div>;
    }
}

WebFont.load({
    google: {
        families: [ 'Michroma' ]
    },

    active: function () {
        ReactDOM.render(
            React.createElement(Main),
            document.getElementById('app')
        );
    }
});
View Compiled
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js
  3. https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.7.1/gl-matrix-min.js
  4. https://cdnjs.cloudflare.com/ajax/libs/webfont/1.6.28/webfontloader.js
  5. https://cdnjs.cloudflare.com/ajax/libs/onecolor/3.1.0/one-color-all.js
  6. https://unpkg.com/[email protected]/build/react-motion.js
  7. https://npmcdn.com/[email protected]/dist/regl.min.js