<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
This Pen doesn't use any external CSS resources.