We have an app with many timeseries charts (custom made with d3), where each chart displays values from different hardware from a tool. Each chart can be displayed in either linear, logarithmic or normalized scale (domain = [0, 1]). We were recently asked to provide a new scale option, we called it config scale, where the user would provide a range (min/max in a configuration file) and all charts would then have the same y axis range.
I thought that since we needed to transform our data twice, I would create 2 d3 scales, like the following:
- yScale => from original data domain => to configured range
- configScale => from configured range => to pixels
I tried to create a repro in https://jsfiddle.net/XeniaSiskaki/skf42jot/159/ (only the new config scale is displayed, with random data points). The big problem I have (assuming that my solution to the original request was correct) is that zooming on y axis is somewhat broken. When either dragging the body of the chart or using the mouse wheel to zoom in/out, zooming is very fast and it appears that the data and the y axis aren't updated properly. Reseting the y viewport also doesn't work properly, don't know if it's related to the zoom issue
const zoom = ['x', 'y'];
const range = [-10, 10];
const sampleDate = new Date('2020-01-01T09:00:00.000Z').getTime();
const innerWidth = 390;
const innerHeight = 278;
const chartData = {
a: [
{
v: 0,
t: sampleDate - 10000
},
{
v: -1,
t: sampleDate - 9000
},
{
v: 0.6,
t: sampleDate - 8000
},
{
v: 1,
t: sampleDate - 7000
},
{
v: -2,
t: sampleDate - 6000
},
{
v: 10,
t: sampleDate - 5000
},
{
v: 0.8,
t: sampleDate - 4000
},
{
v: 5.3,
t: sampleDate - 3000
},
{
v: 0,
t: sampleDate - 2000
},
{
v: 1.2,
t: sampleDate - 1000
},
{
v: 3,
t: sampleDate
},
{
v: 4.5,
t: sampleDate + 1000
},
{
v: 10,
t: sampleDate + 2000
}
],
b: [
{
v: 0.8,
t: sampleDate - 10000
},
{
v: 11,
t: sampleDate - 9000
},
{
v: -7.9,
t: sampleDate - 8000
},
{
v: 22.1,
t: sampleDate - 7000
},
{
v: -0.3,
t: sampleDate - 6000
},
{
v: 10,
t: sampleDate - 5000
},
{
v: 1.3,
t: sampleDate - 4000
},
{
v: 4.3,
t: sampleDate - 3000
},
{
v: 0,
t: sampleDate - 2000
},
{
v: -14.4,
t: sampleDate - 1000
},
{
v: 3,
t: sampleDate
},
{
v: -0.1,
t: sampleDate + 1000
},
{
v: 10,
t: sampleDate + 2000
}
]
};
const allPoints = chartData.a.concat(chartData.b);
class Chart extends React.Component {
constructor(props) {
super(props);
const [x0, x1] = d3.extent(allPoints.map(({ t }) => t));
this.state = {
zoom: 1,
viewport: {
x0,
x1
}
};
this.zoomPane = React.createRef();
this.svg = React.createRef();
this.line = d3.svg
.line()
.interpolate('linear')
.x(({ t }) => this.xScale(t))
.y(({ v }) => this.configScale(this.yScale(v)));
}
render() {
return (
<div>
<div>
<button onClick={this.handleZoomChange.bind(this)}>
Set zoom to {zoom[this.getNextZoomIndex()]}
</button>
<button onClick={this.resetViewport.bind(this)}> Reset viewport </button>
</div>
<div
style={{
height: 300
}}>
<svg width={450} height={300} ref={this.svg}>
<rect
ref={this.zoomPane}
style={{
cursor: 'move'
}}
width={innerWidth}
height={innerHeight}
fill='transparent'
transform='translate(55,0)'>
</rect>
<defs>
<clipPath id={'clipId'}>
<rect x={5} y={5} width={innerWidth - 5} height={innerHeight - 5} />
</clipPath>
</defs>
<g className='x axis' transform={'translate(55,281)'} />
<g className='y axis' transform={'translate(55,0)'} />
<g
clipPath={'url(#clipId)'}
width={innerWidth}
height={innerHeight}
transform={'translate(55,0)'}>
{this.renderLines()}
</g>
</svg>
</div>
</div>
);
}
UNSAFE_componentWillMount() {
this.setupScales();
this.updateScaleDomains();
}
componentDidMount() {
this.xAxis = d3.svg
.axis()
.orient('bottom')
.ticks(450 / 150)
.tickFormat(date => new Date(date).toISOString());
this.yAxis = d3.svg.axis().orient('left');
this.zoom = d3.behavior.zoom().on('zoom', this.handleZoom.bind(this));
this.updateAxes();
this.updateZoom();
d3.select(this.zoomPane.current).call(this.zoom);
}
UNSAFE_componentWillUpdate(nextProps, nextState) {
const { viewport, zoom } = this.state;
if (!_.isEqual(nextState.viewport, viewport)) {
this.updateScaleDomains(nextState);
}
}
componentDidUpdate(prevProps, prevState) {
if (!_.isEqual(prevState.viewport, this.state.viewport)) {
this.updateAxes();
if (prevState.viewport.y0 !== this.state.viewport.y0) {
this.updateZoom();
}
} else if (prevState.zoom !== this.state.zoom) {
this.updateZoom();
}
}
setupScales() {
this.xScale = d3.time.scale().range([10, 380]);
this.yScale = d3.scale.linear().range(range);
this.configScale = d3.scale.linear().domain(range).range([263, 15]);
}
updateScaleDomains(state = this.state) {
const { x0, x1, y0, y1 } = state.viewport;
if (x0 && y0) {
this.xScale.domain([x0, x1]);
this.xScale.range([10, 380]);
this.yScale.domain([y0, y1]);
this.yScale.range(range);
return;
}
if (x0) {
this.xScale.domain([x0, x1]);
} else {
this.xScale.domain(d3.extent(allPoints.map(({ t }) => new Date(t))));
this.xScale.range([10, 380]);
}
if (y0) {
this.yScale.domain([y0, y1]);
} else {
this.yScale.domain(d3.extent(allPoints.map(({ v }) => v)));
this.yScale.range(range);
}
}
updateZoom() {
const z = zoom[this.state.zoom];
if (z === 'x') {
this.zoom.x(this.xScale);
const x0 = this.xScale.invert(10);
const x1 = this.xScale.invert(innerWidth - 10);
const maxScale = (x1.getTime() - x0.getTime()) / 3600;
this.zoom.scaleExtent([0, maxScale]);
} else {
this.zoom.x(d3.scale.identity());
this.zoom.scaleExtent([0, Infinity]);
}
if (z === 'y') {
this.zoom.y(this.configScale);
} else {
this.zoom.y(d3.scale.identity());
}
}
resetViewport() {
this.setState(({ viewport }) => ({
viewport: _.omit(viewport, 'y0', 'y1')
}));
}
updateAxes() {
const svg = d3.select(this.svg.current);
this.xAxis.scale(this.xScale).tickSize(-innerHeight);
this.yAxis.scale(this.configScale).tickSize(-innerWidth);
svg.select('.x.axis').call(this.xAxis);
svg.select('.y.axis').call(this.yAxis);
}
getViewport() {
return {
x0: this.xScale.invert(10),
x1: this.xScale.invert(380),
y0: this.yScale.invert(this.configScale.invert(263)),
y1: this.yScale.invert(this.configScale.invert(15))
};
}
handleZoom() {
d3.select(this.zoomPane.current).call(this.zoom);
this.setState({
viewport: this.getViewport()
});
}
handleZoomChange() {
this.setState({
zoom: this.getNextZoomIndex()
});
this.updateZoom();
}
getNextZoomIndex() {
return this.state.zoom + 1 >= zoom.length ? 0 : this.state.zoom + 1;
}
renderLines() {
return Object.keys(chartData).map(key => {
const d = this.line(chartData[key]);
if (!d) {
return null;
}
return (
<path
d={d}
strokeLinecap='round'
strokeLinejoin='bevel'
fill='none'
strokeWidth={1}
stroke='#000000'
key={key} />
);
});
}
}
ReactDOM.render(<Chart />, document.querySelector('#app'));
svg {
font: 10px sans-serif;
}
button {
width: 120px;
margin: 0 15px;
}
.axis path,
.axis line {
fill: none;
stroke: #000000;
shape-rendering: crispEdges;
}
.axis path {
display: none;
}
.tick line {
stroke: rgba(220, 220, 220, 0.6);
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.17/d3.min.js"></script>
<div id="app"></div>
PS: Using d3 v3