D3:慢速可缩放热图

Aen*_*aon 5 javascript zooming d3.js d3heatmap

我有这个可缩放的热图,放大或缩小时看起来太慢了。有没有什么可以让它更快/更流畅,或者它的积分太多了,这是我能拥有的最好的。我想知道是否有一些技巧可以让浏览器更轻,同时保持工具提示等增强功能。或者也许我的代码处理缩放功能不是很好。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <style>
        .axis text {
            font: 10px sans-serif;
        }

        .axis path,
        .axis line {
            fill: none;
            stroke: #000000;
        }

        .x.axis path {
            //display: none;
        }

        .chart rect {
            fill: steelblue;
        }

        .chart text {
            fill: white;
            font: 10px sans-serif;
            text-anchor: end;
        }
        
        #tooltip {
          position:absolute;
          background-color: #2B292E;
          color: white;
          font-family: sans-serif;
          font-size: 15px;
          pointer-events: none; /*dont trigger events on the tooltip*/
          padding: 15px 20px 10px 20px;
          text-align: center;
          opacity: 0;
          border-radius: 4px;
        }
    </style>
    <title>Bar Chart</title>

    <!-- Reference style.css -->
    <!--    <link rel="stylesheet" type="text/css" href="style.css">-->

    <!-- Reference minified version of D3 -->
    <script src='https://d3js.org/d3.v4.min.js' type='text/javascript'></script>
    <script src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'></script>
</head>

<body>
    <div id="chart" style="width: 700px; height: 500px"></div>
    <script>
        var dataset = [];
        for (let i = 1; i < 360; i++) {
            for (j = 1; j < 75; j++) {
                dataset.push({
                    day: i,
                    hour: j,
                    tOutC: Math.random() * 25,
                })
            }
        };


        var days = d3.max(dataset, function(d) {
                return d.day;
            }) -
            d3.min(dataset, function(d) {
                return d.day;
            });
        var hours = d3.max(dataset, function(d) {
                return d.hour;
            }) -
            d3.min(dataset, function(d) {
                return d.hour;
            });

        var tMin = d3.min(dataset, function(d) {
                return d.tOutC;
            }),
            tMax = d3.max(dataset, function(d) {
                return d.tOutC;
            });

        var dotWidth = 1,
            dotHeight = 3,
            dotSpacing = 0.5;

        var margin = {
                top: 0,
                right: 25,
                bottom: 40,
                left: 25
            },
            width = (dotWidth * 2 + dotSpacing) * days,
            height = (dotHeight * 2 + dotSpacing) * hours; 


        var colors = ['#2C7BB6', '#00A6CA','#00CCBC','#90EB9D','#FFFF8C','#F9D057','#F29E2E','#E76818','#D7191C'];

        var xScale = d3.scaleLinear()
            .domain(d3.extent(dataset, function(d){return d.day}))
            .range([0, width]);

        var yScale = d3.scaleLinear()
            .domain(d3.extent(dataset, function(d){return d.hour}))
            .range([(dotHeight * 2 + dotSpacing) * hours, dotHeight * 2 + dotSpacing]);

        var colorScale = d3.scaleQuantile()
            .domain([0, colors.length - 1, d3.max(dataset, function(d) {
                return d.tOutC;
            })])
            .range(colors);

        var xAxis = d3.axisBottom().scale(xScale);



        // Define Y axis
        var yAxis = d3.axisLeft().scale(yScale);


        var zoom = d3.zoom()
            .scaleExtent([dotWidth, dotHeight])
            .translateExtent([
                [80, 20],
                [width, height]
            ])
            .on("zoom", zoomed);

        var tooltip = d3.select("body").append("div")
        .attr("id", "tooltip")
        .style("opacity", 0);

        // SVG canvas
        var svg = d3.select("#chart")
            .append("svg")
            .attr("width", width + margin.left + margin.right)
            .attr("height", height + margin.top + margin.bottom)
            .call(zoom)
            .append("g")
            .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

        // Clip path
        svg.append("clipPath")
            .attr("id", "clip")
            .append("rect")
            .attr("width", width)
            .attr("height", height);


        // Heatmap dots
        svg.append("g")
            .attr("clip-path", "url(#clip)")
            .selectAll("ellipse")
            .data(dataset)
            .enter()
            .append("ellipse")
            .attr("cx", function(d) {
                return xScale(d.day);
            })
            .attr("cy", function(d) {
                return yScale(d.hour);
            })
            .attr("rx", dotWidth)
            .attr("ry", dotHeight)
            .attr("fill", function(d) {
                return colorScale(d.tOutC);
            })
            .on("mouseover", function(d){
                $("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
                var xpos = d3.event.pageX +10;
                var ypos = d3.event.pageY +20;
                $("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
            }).on("mouseout", function(){
                $("#tooltip").animate({duration: 500}).css("opacity",0);
            }); 

        //Create X axis
        var renderXAxis = svg.append("g")
            .attr("class", "x axis")
            .attr("transform", "translate(0," + yScale(0) + ")")
            .call(xAxis)

        //Create Y axis
        var renderYAxis = svg.append("g")
            .attr("class", "y axis")
            .call(yAxis);


        function zoomed() {
            // update: rescale x axis
            renderXAxis.call(xAxis.scale(d3.event.transform.rescaleX(xScale)));

            update();
        }

        function update() {
            // update: cache rescaleX value
            var rescaleX = d3.event.transform.rescaleX(xScale);
            svg.selectAll("ellipse")
                .attr('clip-path', 'url(#clip)')
                // update: apply rescaleX value
                .attr("cx", function(d) {
                    return rescaleX(d.day);
                })
//                .attr("cy", function(d) {
//                    return yScale(d.hour);
//                })
                // update: apply rescaleX value
                .attr("rx", function(d) {
                    return (dotWidth * d3.event.transform.k);
                })
                .attr("fill", function(d) {
                    return colorScale(d.tOutC);
                });
        }
        

        
        
    </script>
</body>

</html>
Run Code Online (Sandbox Code Playgroud)

谢谢

And*_*eid 5

尝试画布

您有 27,000 个节点。对于大多数人来说,这可能是 SVG 性能下降而 Canvas 开始真正发挥作用的点。当然,Canvas 不像 SVG 那样有状态,它只是像素,没有很好的元素可以在 DOM 中将鼠标悬停在上面并告诉您它们在哪里以及它们是什么。但是,有一些方法可以解决这个缺点,以便我们可以保持速度和交互能力。

对于使用您的代码片段的初始渲染,我的平均渲染时间约为 440 毫秒。但是,通过画布的魔力,我可以渲染相同的热图,平均渲染时间约为 103 毫秒。这些节省可以应用于缩放、动画等。

对于像椭圆这样的非常小的东西,存在锯齿问题的风险,与 SVG 相比,使用 canvas 更难修复这种问题,尽管每个浏览器呈现此问题的方式会有所不同

设计意义

使用 Canvas,我们可以像 SVG 一样保留进入/退出/更新周期,但我们也可以选择删除它。有时,进入/退出/更新周期与画布配合得非常好:过渡、动态数据、层次数据等。我之前花了一些时间讨论 Canvas 和 SVG 之间关于 D3 的一些更高级别的差异。

对于我在这里的回答,我们将离开输入周期。当我们想要更新可视化时,我们只需根据数据数组本身重新绘制所有内容。

绘制热图

为了简洁起见,我使用矩形。Canvas 的椭圆方法还没有完全准备好,但您可以很容易地模拟它

我们需要一个绘制数据集的函数。如果您将 x/y/color 硬编码到数据集中,我们可以使用非常简单的方法:

function drawNodes()
  dataset.forEach(function(d) {
    ctx.beginPath();
    ctx.rect(d.x,d.y,width,height);
    ctx.fillStyle = d.color;
    ctx.fill(); 
  })    
}
Run Code Online (Sandbox Code Playgroud)

但是我们需要缩放您的值,计算颜色,并且我们应该应用缩放。我最终得到了一个相对简单的结果:

function drawNodes()
  var k = d3.event ? d3.event.transform.k : 1;
  var dw = dotWidth * k;
  ctx.clearRect(0,0,width,height);      // erase what's there
  dataset.forEach(function(d) {
    var x = xScale(d.day);
    var y = yScale(d.hour);
    var fill = colorScale(d.tOutC);
    ctx.beginPath();
    ctx.rect(x,y,dw,dotHeight);
    ctx.fillStyle = fill;
    ctx.strokeStyle = fill;
    ctx.stroke();
    ctx.fill(); 
  })    
}
Run Code Online (Sandbox Code Playgroud)

这可用于初始绘制节点(当未定义 d3.event 时),或用于缩放/平移事件(之后每次调用此函数)。

那么轴呢?

d3 轴适用于 SVG。因此,我只是将 SVG 叠加在 Canvas 元素之上,在覆盖的 SVG 上绝对定位并禁用鼠标事件。

说到轴,我只有一个绘图函数(更新/初始绘图之间没有区别),因此我从一开始就使用参考 x 比例和渲染 x 比例,而不是在更新函数中创建一次性重新缩放的 x 比例

现在我有了画布,我如何与其交互?

我们可以使用几种方法获取像素位置并将其转换为特定的数据:

  • 使用 Voronoi 图(使用 .find 方法定位数据)
  • 使用强制布局(也使用 .find 方法来定位基准)
  • 使用隐藏的画布(使用像素颜色来指示基准索引)
  • 使用比例尺的反转功能(当数据网格化时)

第三个选项可能是最常见的选项之一,虽然前两个看起来相似,但查找方法在内部确实有所不同(voronoi 邻居与四叉树)。最后一种方法在这种情况下相当合适:我们有一个数据网格,我们可以反转鼠标坐标来获取行和列数据。根据您的代码片段,可能如下所示:

function mousemove() {
  var xy = d3.mouse(this);
  var x = Math.round(xScale.invert(xy[0]));
  var y = Math.round(yScale.invert(xy[1]));
  // For rounding on canvas edges:
  if(x > xScaleRef.domain()[1]) x = xScaleRef.domain()[1];
  if(x < xScaleRef.domain()[0]) x = xScaleRef.domain()[0];
  if(y > yScale.domain()[1]) y = yScale.domain()[1];
  if(y < yScale.domain()[0]) y = yScale.domain()[0];

  var index = --x*74 + y-1;  // minus ones for non zero indexed x,y values.
  var d = dataset[index];
  console.log(x,y,index,d)

  $("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
  var xpos = d3.event.pageX +10;
  var ypos = d3.event.pageY +20;
  $("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
}
Run Code Online (Sandbox Code Playgroud)

*我使用了mousemove,因为mouseover在画布上移动时会触发一次,我们需要不断更新,如果我们想隐藏工具提示,我们可以检查所选的像素是否为白色:

var p = ctx.getImageData(xy[0], xy[1], 1, 1).data; // pixel data:
if (!p[0] && !p[1] && !p[2])   {  /* show tooltip */ }
else {  /* hide tooltip */ }
Run Code Online (Sandbox Code Playgroud)

例子

我已经明确提到了上面的大部分更改,但我在下面做了一些额外的更改。首先,我需要选择画布,定位它,获取上下文等。我还用椭圆交换了矩形,因此定位有点不同(但是使用线性比例(椭圆质心)会遇到其他定位问题可以落在 svg 的边缘上),我没有修改它来考虑椭圆/矩形的宽度/高度。这个比例问题与我没有修改它的问题足够远。

function drawNodes()
  dataset.forEach(function(d) {
    ctx.beginPath();
    ctx.rect(d.x,d.y,width,height);
    ctx.fillStyle = d.color;
    ctx.fill(); 
  })    
}
Run Code Online (Sandbox Code Playgroud)
function drawNodes()
  var k = d3.event ? d3.event.transform.k : 1;
  var dw = dotWidth * k;
  ctx.clearRect(0,0,width,height);      // erase what's there
  dataset.forEach(function(d) {
    var x = xScale(d.day);
    var y = yScale(d.hour);
    var fill = colorScale(d.tOutC);
    ctx.beginPath();
    ctx.rect(x,y,dw,dotHeight);
    ctx.fillStyle = fill;
    ctx.strokeStyle = fill;
    ctx.stroke();
    ctx.fill(); 
  })    
}
Run Code Online (Sandbox Code Playgroud)
function mousemove() {
  var xy = d3.mouse(this);
  var x = Math.round(xScale.invert(xy[0]));
  var y = Math.round(yScale.invert(xy[1]));
  // For rounding on canvas edges:
  if(x > xScaleRef.domain()[1]) x = xScaleRef.domain()[1];
  if(x < xScaleRef.domain()[0]) x = xScaleRef.domain()[0];
  if(y > yScale.domain()[1]) y = yScale.domain()[1];
  if(y < yScale.domain()[0]) y = yScale.domain()[0];

  var index = --x*74 + y-1;  // minus ones for non zero indexed x,y values.
  var d = dataset[index];
  console.log(x,y,index,d)

  $("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
  var xpos = d3.event.pageX +10;
  var ypos = d3.event.pageY +20;
  $("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
}
Run Code Online (Sandbox Code Playgroud)


rio*_*oV8 4

解决方案不是更新所有缩放点,而是将缩放变换应用于包含点的组。需要在另一个父级上完成组的剪辑g heatDotsGroup

y 的缩放比例通过正则表达式替换来处理(将其固定为 1),通过将 y 设置transform.y为 0 来限制 y 的平移,并根据当前比例限制 x 的平移。

0放大时允许稍微平移以显示第一个点。

    var zoom = d3.zoom()
        .scaleExtent([dotWidth, dotHeight])
        .on("zoom", zoomed);

    // Heatmap dots
    var heatDotsGroup = svg.append("g")
        .attr("clip-path", "url(#clip)")
        .append("g");

    heatDotsGroup.selectAll("ellipse")
        .data(dataset)
        .enter()
        .append("ellipse")
        .attr("cx", function(d) { return xScale(d.day); })
        .attr("cy", function(d) { return yScale(d.hour); })
        .attr("rx", dotWidth)
        .attr("ry", dotHeight)
        .attr("fill", function(d) { return colorScale(d.tOutC); })
        .on("mouseover", function(d){
            $("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
            var xpos = d3.event.pageX +10;
            var ypos = d3.event.pageY +20;
            $("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
        }).on("mouseout", function(){
            $("#tooltip").animate({duration: 500}).css("opacity",0);
        }); 

    function zoomed() {
        d3.event.transform.y = 0;
        d3.event.transform.x = Math.min(d3.event.transform.x, 5);
        d3.event.transform.x = Math.max(d3.event.transform.x, (1-d3.event.transform.k) * width );

        // update: rescale x axis
        renderXAxis.call(xAxis.scale(d3.event.transform.rescaleX(xScale)));

        heatDotsGroup.attr("transform", d3.event.transform.toString().replace(/scale\((.*?)\)/, "scale($1, 1)"));
    }
Run Code Online (Sandbox Code Playgroud)