D3 semantic zooming with Reusable Pattern

怎甘沉沦 提交于 2021-02-07 20:54:16

问题


I'm trying to implement semantic zooming while using Mike Bostock's Towards Reusable Charts pattern (where a chart is represented as a function). In my zoom handler, I'd like to use transform.rescaleX to update my scale and then simply call the function again.

It almost works but the rescaling seems to accumulate zoom transforms getting faster and faster. Here's my fiddle:

function chart() {
  let aspectRatio = 10.33;
  let margin = { top: 0, right: 0, bottom: 5, left: 0 };
  let current = new Date();
  let scaleBand = d3.scaleBand().padding(.2);
  let scaleTime = d3.scaleTime().domain([d3.timeDay(current), d3.timeDay.ceil(current)]);
  let axis = d3.axisBottom(scaleTime);
  let daysThisMonth = d3.timeDay.count(d3.timeMonth(current), d3.timeMonth.ceil(current));
  let clipTypes = [ClipType.Scheduled, ClipType.Alarm, ClipType.Motion];
  let zoom = d3.zoom().scaleExtent([1 / daysThisMonth, 1440]);
  let result = function(selection) {
    selection.each(function(data) {
      let selection = d3.select(this);
      let outerWidth = this.getBoundingClientRect().width;
      let outerHeight = outerWidth / aspectRatio;
      let width = outerWidth - margin.left - margin.right;
      let height = outerHeight - margin.top - margin.bottom;
      scaleBand.domain(d3.range(data.length)).range([0, height * .8]);
      scaleTime.range([0, width]);
      zoom.on('zoom', _ => {
        scaleTime = d3.event.transform.rescaleX(scaleTime);
        selection.call(result);
      });
      let svg = selection.selectAll('svg').data([data]);
      let svgEnter = svg.enter().append('svg').attr('viewBox', '0 0 ' + outerWidth + ' ' + outerHeight);//.attr('preserveAspectRatio', 'xMidYMin slice');
      svg = svg.merge(svgEnter);
      	let defsEnter = svgEnter.append('defs');
      	let defs = svg.select('defs');
      	let gMainEnter = svgEnter.append('g').attr('id', 'main');
      	let gMain = svg.select('g#main').attr('transform', 'translate(' + margin.left + ' ' + margin.top + ')');
          let gAxisEnter = gMainEnter.append('g').attr('id', 'axis');
          let gAxis = gMain.select('g#axis').call(axis.scale(scaleTime));
          let gCameraContainerEnter = gMainEnter.append('g').attr('id', 'camera-container');
          let gCameraContainer = gMain.select('g#camera-container').attr('transform', 'translate(' + 0 + ' ' + height * .2 + ')').call(zoom);
      			let gCameraRowsEnter = gCameraContainerEnter.append('g').attr('id', 'camera-rows');
            let gCameraRows = gCameraContainer.select('g#camera-rows');
              let gCameras = gCameraRows.selectAll('g.camera').data(d => {
                return d;
              });
              let gCamerasEnter = gCameras.enter().append('g').attr('class', 'camera');
              gCameras = gCameras.merge(gCamerasEnter);
              gCameras.exit().remove();
                let rectClips = gCameras.selectAll('rect.clip').data(d => {
                  return d.clips.filter(clip => {
                    return clipTypes.indexOf(clip.type) !== -1;
                  });
                });
                let rectClipsEnter = rectClips.enter().append('rect').attr('class', 'clip').attr('height', _ => {
                  return scaleBand.bandwidth();
                }).attr('y', (d, i, g) => {
                  return scaleBand(Array.prototype.indexOf.call(g[i].parentNode.parentNode.childNodes, g[i].parentNode)); //TODO: sloppy
                }).style('fill', d => {
                  switch(d.type) {
                    case ClipType.Scheduled:
                      return '#0F0';
                    case ClipType.Alarm:
                      return '#FF0';
                    case ClipType.Motion:
                      return '#F00';
                  };
                });
                rectClips = rectClips.merge(rectClipsEnter).attr('width', d => {
                  return scaleTime(d.endTime) - scaleTime(d.startTime);
                }).attr('x', d => {
                  return scaleTime(d.startTime);
                });
                rectClips.exit().remove();
      			let rectBehaviorEnter = gCameraContainerEnter.append('rect').attr('id', 'behavior').style('fill', '#000').style('opacity', 0);
          	let rectBehavior = gCameraContainer.select('rect#behavior').attr('width', width).attr('height', height * .8);//.call(zoom);
    });
  };
  return result;
}

// data model

let ClipType = {
  Scheduled: 0,
  Alarm: 1,
  Motion: 2
};
let data = [{
  id: 1,
  src: "assets/1.jpg",
  name: "Camera 1",
  server: 1
}, {
  id: 2,
  src: "assets/2.jpg",
  name: "Camera 2",
  server: 1
}, {
  id: 3,
  src: "assets/1.jpg",
  name: "Camera 3",
  server: 2
}, {
  id: 4,
  src: "assets/1.jpg",
  name: "Camera 4",
  server: 2
}].map((_ => {
  let current = new Date();
  let randomClips = d3.randomUniform(24);
  let randomTimeSkew = d3.randomUniform(-30, 30);
  let randomType = d3.randomUniform(3);
  return camera => {
    camera.clips = d3.timeHour.every(Math.ceil(24 / randomClips())).range(d3.timeDay.offset(current, -30), d3.timeDay(d3.timeDay.offset(current, 1))).map((d, indexEndTime, g) => {
      return {
        startTime: indexEndTime === 0 ? d : d3.timeMinute.offset(d, randomTimeSkew()),
        endTime: indexEndTime === g.length - 1 ? d3.timeDay(d3.timeDay.offset(current, 1)) : null,
        type: Math.floor(randomType())
      };
    }).map((d, indexStartTime, g) => {
      if(d.endTime === null)
        d.endTime = g[indexStartTime + 1].startTime;
      return d;
    });
    return camera;
  };
})());
let myChart = chart();
let selection = d3.select('div#container');
selection.datum(data).call(myChart);
<div id="container"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>

Edit: The zoom handler below works fine, but I'd like a more general solution:

let newScaleTime = d3.event.transform.rescaleX(scaleTime);
d3.select('g#axis').call(axis.scale(newScaleTime));
d3.selectAll('rect.clip').attr('width', d => {
  return newScaleTime(d.endTime) - newScaleTime(d.startTime);
}).attr('x', d => {
  return newScaleTime(d.startTime);
});

回答1:


The short answer is you need to implement a reference scale to indicate what the scale's base state is when unmanipulated by the zoom. Otherwise you will run into the problem you describe: "It almost works but the rescaling seems to accumulate zoom transforms getting faster and faster. "

To see why a reference scale is needed, zoom in on the graph and out (once each) without moving the mouse. When you zoom in, the axis changes. When you zoom out the axis does not. Note the scale factor on the intial zoom in and the first time you zoom out: 1.6471820345351462 on the zoom in, 1 on the zoom out. The number represents how much the to magnify/minify whatever it is we are zooming in on. On the initial zoom in we magnify by a factor of ~1.65. On the preceding zoom out we minify by a factor of 1, ie: not at all. If on the other hand you zoom out first, you minify by a factor of about 0.6 and then if you were to zoom in you magnify by a factor of 1. I've built a stripped down of your example to show this:

function chart() {
  let zoom = d3.zoom().scaleExtent([0.25,20]);
  let scale = d3.scaleLinear().domain([0,1000]).range([0,550]);
  let axis = d3.axisBottom;
  
  let result = function(selection) {
    selection.each(function() {
      
      let selection = d3.select(this);
         
      selection.call(axis(scale));
      selection.call(zoom);
      
      zoom.on('zoom', function() {
         scale = d3.event.transform.rescaleX(scale);
         console.log(d3.event.transform.k);
         selection.call(result);
      });
    
    })
  }
  return result;
}

d3.select("svg").call(chart());
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.min.js"></script>
<svg width="550" height="200"></svg>

The scale should be relative to the initial zoom factor, usually 1. In otherwords, the zoom is cumulative, it records magnification/minification as a factor of the initial scale, not the last step (otherwise transform k values would only be one of three values: one value for zooming out, another for zooming in and one for remaining the same and all relative to the current scale). This is why rescaling the initial scale doesn't work - you lose the reference point to the initial scale that the zoom is referencing.

From the docs, if you redefine a scale with d3.event.transform.rescaleX, we get a scale that reflects the zoom's (cumulative) transformation:

[the rescaleX] method does not modify the input scale x; x thus represents the untransformed scale, while the returned scale represents its transformed view. (docs)

Building on this, if we zoom in twice in a row, the first time we zoom in we see the transform.k value is ~1.6x on the first time, the second time it is ~2.7x. But, since we rescale the scale, we apply a zoom of 2.7x on a scale that has already been zoomed in 1.6x, giving us a scale factor of ~4.5x rather than 2.7x. To make matters worse, if we zoom in twice and then out once, the zoom (out) event gives us a scale value that is still greater than 1 (~1.6 on first zoom in, ~2.7 on second, ~1.6 on zoom out), hence we are still zooming in despite scrolling out:

function chart() {
  let zoom = d3.zoom().scaleExtent([0.25,20]);
  let scale = d3.scaleLinear().domain([0,1000]).range([0,550]);
  let axis = d3.axisBottom;
  
  let result = function(selection) {
    selection.each(function() {
      
      let selection = d3.select(this);
         
      selection.call(axis(scale));
      selection.call(zoom);
      
      zoom.on('zoom', function() {
         scale = d3.event.transform.rescaleX(scale);
         var magnification = 1000/(scale.domain()[1] - scale.domain()[0]);
         console.log("Actual magnification: "+magnification+"x");
         console.log("Intended magnification: "+d3.event.transform.k+"x")
         console.log("---");         
         selection.call(result);
      });
    
    })
  }
  return result;
}

d3.select("svg").call(chart());
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.min.js"></script>
<svg width="550" height="200"></svg>

I haven't discussed the x offset portion of the zoom, but you can imagine that a similar problem occurs - the zoom is cumulative but you lose the initial reference point that those cumulative changes are in reference to.

The idiomatic solution is to use a reference scale and the zoom to create a working scale used for plotting rectangles/axes/etc. The working scale is initially the same as the reference scale (generally) and is set as so: workingScale = d3.event.transform.rescaleX(referenceScale) on each zoom.

function chart() {
  let zoom = d3.zoom().scaleExtent([0.25,20]);
  let workingScale = d3.scaleLinear().domain([0,1000]).range([0,550]);
  let referenceScale = d3.scaleLinear().domain([0,1000]).range([0,550]);
  let axis = d3.axisBottom;
  
  let result = function(selection) {
    selection.each(function() {
      
      let selection = d3.select(this);
      
      selection.call(axis(workingScale));
      selection.call(zoom);
      
      zoom.on('zoom', function() {
         workingScale = d3.event.transform.rescaleX(referenceScale);
         var magnification = 1000/(workingScale.domain()[1] - workingScale.domain()[0]);
         console.log("Actual magnification: "+magnification+"x");
         console.log("Intended magnification: "+d3.event.transform.k+"x")
         console.log("---");         
         selection.call(result);
      });
    
    })
  }
  return result;
}

d3.select("svg").call(chart());
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.min.js"></script>
<svg width="550" height="200"></svg>


来源:https://stackoverflow.com/questions/50143819/d3-semantic-zooming-with-reusable-pattern

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!