Companion gist for blog post: Bridging UI libraries for decoupled, long-lived, widely-reusable components
Last active
April 18, 2019 19:19
-
-
Save robatwilliams/9a978cc72e6a842638d6995ddcaf1bda to your computer and use it in GitHub Desktop.
Framework adapters: D3-for-React + React-for-D3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as d3 from 'd3'; | |
import PropTypes from 'prop-types'; | |
import React, { PureComponent } from 'react'; | |
/** | |
* Adapter for using D3 components in React. | |
* | |
* To be compatible, D3 components must follow the reusable component convention | |
* as proposed by Mike Bostock: https://bost.ocks.org/mike/chart/ | |
* | |
* Features: | |
* - forwarding of props to setters on initialisation and update | |
* - attachment of event handlers (via props of the form onEventName) | |
* - optional component destroy() method called on unmount | |
* | |
* Ideas: | |
* - allow root element attributes to be set, for styling etc. (via prop, or via children?) | |
* - support prop forwarding to property setters, in case component is an ES6 class | |
*/ | |
export default class D3Adapter extends PureComponent { | |
// Names may conflict with those that need passing through to the wrapped component. | |
// Must update transitProps() when accepted props are changed. | |
static propTypes = { | |
component: PropTypes.func.isRequired, | |
data: PropTypes.any, | |
} | |
transitEventHandlers = {}; | |
constructor(props) { | |
super(props); | |
this.root = React.createRef(); | |
this.forwardTransitProps(); | |
} | |
componentDidMount() { | |
this.renderWrapped(); | |
this.addTransitEventListeners(); | |
} | |
componentDidUpdate(prevProps) { | |
this.forwardTransitProps(prevProps); | |
this.renderWrapped(); | |
} | |
componentWillUnmount() { | |
if (typeof this.wrappedComponent.destroy === 'function') { | |
this.wrappedComponent.destroy(); | |
} | |
this.removeTransitEventListeners(); | |
} | |
get wrappedComponent() { | |
return this.props.component; | |
} | |
/** | |
* assumption: components dispatch their events on that root element they create | |
*/ | |
get transitEventTarget() { | |
return this.root.current.firstChild; | |
} | |
get transitProps() { | |
const { component, data, ...others } = this.props; | |
return filterObject(others, ([name]) => !(name in this.eventHandlerProps)); | |
} | |
get eventHandlerProps() { | |
return filterObject(this.props, ([name]) => /^on[A-Z].*/.test(name)); | |
} | |
/** | |
* assumption: component event names are in lower case (web convention) | |
*/ | |
addTransitEventListeners() { | |
for (const [propName, handler] of Object.entries(this.eventHandlerProps)) { | |
const eventName = propName.slice(2).toLowerCase(); | |
this.transitEventHandlers[eventName] = this.transitEventTarget.addEventListener(eventName, handler); | |
} | |
} | |
removeTransitEventListeners() { | |
for (const [eventName, handler] of Object.entries(this.transitEventHandlers)) { | |
this.transitEventTarget.removeEventListener(eventName, handler); | |
} | |
} | |
forwardTransitProps(prevProps) { | |
for (const [name, value] of Object.entries(this.transitProps)) { | |
if (!prevProps || value !== prevProps[name]) { | |
this.forwardTransitProp(name, value); | |
} | |
} | |
} | |
forwardTransitProp(name, value) { | |
const setter = this.wrappedComponent[name]; | |
if (typeof setter === 'function') { | |
setter(value); | |
} else { | |
throw new Error(`Wrapped component does not support prop "${name}"`); | |
} | |
} | |
renderWrapped() { | |
const { data } = this.props; | |
d3.select(this.root.current) | |
.datum(data) | |
.call(this.wrappedComponent); | |
} | |
/** | |
* Not doing shouldComponentUpdate => false, as sometimes recommended. | |
* The D3-rendered elements won't be in the virtual DOM, so the reconciliation is unaffected. | |
* By allowing update, we can use async-compatible (non-deprecated) lifecycle methods, and | |
* also allow root element attributes to be updated. | |
* | |
* assumption: components create their own root element within a <div> given to them (seems to be the convention) | |
*/ | |
render() { | |
return ( | |
<div ref={this.root}></div> | |
); | |
} | |
} | |
const objectify = (object, [key, value]) => ({ ...object, [key]: value }); | |
const filterObject = (object, predicate) => Object.entries(object) | |
.filter(predicate) | |
.reduce(objectify, {}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { PureComponent } from 'react'; | |
import D3Adapter from '../adapter/D3Adapter'; | |
import timeSeriesChart from './timeSeriesChart'; | |
/** | |
* Example component-specific convenience adapter. | |
*/ | |
export default class D3TimeSeriesChart extends PureComponent { | |
constructor(props) { | |
super(props); | |
this.component = timeSeriesChart(); | |
} | |
render() { | |
return ( | |
<D3Adapter | |
component={this.component} | |
{...this.props} | |
/> | |
); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as d3 from 'd3'; | |
import React from 'react'; | |
import ReactDOM from 'react-dom'; | |
export default function reactAdapter(WrappedComponent) { | |
function adapter(selection) { | |
selection.each(function (d) { | |
ReactDOM.render(<WrappedComponent {...d} />, d3.select(this).node()); | |
}); | |
selection.exit().each(function (d) { | |
ReactDOM.unmountComponentAtNode(d3.select(this).node()); | |
}); | |
} | |
return adapter; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as d3 from 'd3'; | |
import './timeSeriesChart.css'; | |
/** | |
* Update of Mike Bostock's reusable chart to work in D3 v4 (was D3 v2). | |
* | |
* Original: https://bost.ocks.org/mike/chart/time-series-chart.js | |
* Explanation: https://bost.ocks.org/mike/chart/ | |
*/ | |
export default function timeSeriesChart() { | |
var margin = {top: 20, right: 20, bottom: 20, left: 20}, | |
width = 760, | |
height = 120, | |
xValue = function(d) { return d[0]; }, | |
yValue = function(d) { return d[1]; }, | |
xScale = d3.scaleTime(), | |
yScale = d3.scaleLinear(), | |
xAxis = d3.axisBottom().scale(xScale).tickSize(6, 0), | |
area = d3.area().x(X).y1(Y), | |
line = d3.line().x(X).y(Y); | |
function chart(selection) { | |
selection.each(function(data) { | |
// Convert data to standard representation greedily; | |
// this is needed for nondeterministic accessors. | |
data = data.map(function(d, i) { | |
return [xValue.call(data, d, i), yValue.call(data, d, i)]; | |
}); | |
// Update the x-scale. | |
xScale | |
.domain(d3.extent(data, function(d) { return d[0]; })) | |
.range([0, width - margin.left - margin.right]); | |
// Update the y-scale. | |
yScale | |
.domain([0, d3.max(data, function(d) { return d[1]; })]) | |
.range([height - margin.top - margin.bottom, 0]); | |
// Select the svg element, if it exists. | |
var svg = d3.select(this).selectAll("svg").data([data]); | |
var svgEnter = svg.enter().append("svg"); | |
svg = svg.merge(svgEnter); | |
// Otherwise, create the skeletal chart. | |
var gEnter = svgEnter.append("g"); | |
gEnter.append("path").attr("class", "area"); | |
gEnter.append("path").attr("class", "line"); | |
gEnter.append("g").attr("class", "x axis"); | |
// Update the outer dimensions. | |
svg.attr("width", width) | |
.attr("height", height); | |
// Update the inner dimensions. | |
var g = svg.select("g") | |
.attr("transform", "translate(" + margin.left + "," + margin.top + ")"); | |
// Update the area path. | |
g.select(".area") | |
.attr("d", area.y0(yScale.range()[0])); | |
// Update the line path. | |
g.select(".line") | |
.attr("d", line); | |
// Update the x-axis. | |
g.select(".x.axis") | |
.attr("transform", "translate(0," + yScale.range()[0] + ")") | |
.call(xAxis); | |
}); | |
} | |
// The x-accessor for the path generator; xScale ∘ xValue. | |
function X(d) { | |
return xScale(d[0]); | |
} | |
// The x-accessor for the path generator; yScale ∘ yValue. | |
function Y(d) { | |
return yScale(d[1]); | |
} | |
chart.margin = function(_) { | |
if (!arguments.length) return margin; | |
margin = _; | |
return chart; | |
}; | |
chart.width = function(_) { | |
if (!arguments.length) return width; | |
width = _; | |
return chart; | |
}; | |
chart.height = function(_) { | |
if (!arguments.length) return height; | |
height = _; | |
return chart; | |
}; | |
chart.x = function(_) { | |
if (!arguments.length) return xValue; | |
xValue = _; | |
return chart; | |
}; | |
chart.y = function(_) { | |
if (!arguments.length) return yValue; | |
yValue = _; | |
return chart; | |
}; | |
return chart; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment