d3: zoom on double y scale broken

Viewed 237

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

1 Answers

I think there's a few problems here.

First, handleZoom repeatedly registers this.zoom on every zoom event. This is not necessary; the caller only needs to be registered once. This might be part of the reason that after zooming several times, the behaviour gets more extreme (as each zoom event is effectively being performed many times).

Second, updateZoom uses configScale but not yScale. The call to this.zoom.y binds d3's zoom to automatically update the domain of whatever is provided; we don't want to change the domain of the configScale but we do want to change the yScale domain.

Finally, it might be simpler to remove the double scaling and instead do an equivalent calculation on the data before giving it to the graph. This would simplify all your logic quite significantly.

Additionally, I found the approach of storing the zoom as the viewport bounds to be a little strange; consider storing the d3 zoom transform instead (and applying the transform when drawing the lines and axes).

Related