D3.js多图表同步缩放

5
我想制作一条折线图,使其能够与多个网页同步缩放/平移。

enter image description here

这些客户端具有相同的JavaScript和HTML源代码。 用户在客户端A上缩放或平移,数据域的白天时间消息将发送到其他客户端和发送者(如上图中的蓝线),并且接收客户端的图形将同时更改。当然,其他客户端也可以执行相同的操作。 这类似于聊天应用程序。 缩放功能是:
 function zoomed() {
        let msg = [];
        let t = d3.event.transform; //1)

        msg[0] = t.rescaleX(x2).domain()[0].toString(); //2)
        msg[1] = t.rescaleX(x2).domain()[1].toString(); //2)

        sendMessage(msg); //3)
    }
  1. d3.event.transform 捕捉鼠标事件。
  2. 转换为日期时间和字符串。
  3. 将新的比例尺域发送到服务器。

服务器将接收到的数据发送给所有客户端:

function passiveZoom(rcv){
        let leftend;
        let rightend;
        leftend = new Date(rcv[0]);
        rightend = new Date(rcv[1]);

        x.domain([leftend, rightend]);

        svg.select(".line").attr("d", valueline);
        svg.select(".axis").call(xAxis);
    }
  1. 从服务器接收到包含新日期时间的消息。
  2. 设置新域名。
  3. 更新折线图。

通过这样做,可以缩放|平移所有折线图。

然而,它不能按照要求工作。

如果我在客户端A上缩放|平移,客户端B和客户端C也会改变。这没问题。

接下来,在客户端C(上图中的橙色线)上进行缩放|平移,所有图形都会恢复到初始比例和位置。为什么!?

我认为鼠标坐标没有发送给客户端,但是当我发送鼠标的位置坐标时,我该如何处理呢?

缩放|平移过程是从mbostock的块中分叉出来的:Brush & Zoom。发送者还使用t.rescalex(x2).domain()更改X2域的范围。 由于X2在绘图中未被使用,我将X改为x2,但我只能缩小。我不明白X2的含义。

请问如何同步所有客户端?x2是什么?
这段代码是从v4的简单线图分叉出来的客户端。
<!DOCTYPE html>
<meta charset="utf-8">
<style>
/* set the CSS */

body {
    font: 12px Arial;
}

path {
    stroke: steelblue;
    stroke-width: 2;
    fill: none;
}

.zoom {
    cursor: move;
    fill: none;
    pointer-events: all;
}

.axis path,
.axis line {
    fill: none;
    stroke: grey;
    stroke-width: 1;
    shape-rendering: crispEdges;
}
</style>

<body>
    <!-- load the d3.js library -->
    <script src="http://d3js.org/d3.v4.min.js"></script>
     <script src="socket.io.js"></script>
    <script>

        //--- Network----
    let rcvT;
    let socket = io.connect('http://localhost:3000'); 

    //Recive event from server
    socket.on("connect", function() {}); 
    socket.on("disconnect", function(client) {}); 
    socket.on("S_to_C_message", function(data) {
        rcvT = data.value;
        passiveZoom(rcvT);

    });
    socket.on("S_to_C_broadcast", function(data) {
        console.log("Rcv broadcast " + data.value);
        rcvT = data.value;
        passiveZoom(rcvT);
    });

    function sendMessage(msg) {
        socket.emit("C_to_S_message", { value: msg }); //send to server
    }

    function sendBroadcast(msg) {
        socket.emit("C_to_S_broadcast", { value: msg }); // send to server
    }

    // --------------------

    // Set the dimensions of the canvas / graph
    var margin = { top: 30, right: 20, bottom: 30, left: 50 },
        width = 600 - margin.left - margin.right,
        height = 270 - margin.top - margin.bottom;

    // Parse the date / time
    var parseDate = d3.timeParse("%d-%b-%y");

    // Set the ranges
    var x = d3.scaleTime().range([0, width]);
    var y = d3.scaleTime().range([height, 0]);
    var x2 = d3.scaleTime().range([0, width]);

    xAxis = d3.axisBottom(x)
        .tickFormat(d3.timeFormat('%d-%b-%y'))
        .ticks(5);

    // var yAxis = d3.svg.axis().scale(y)
    //     .orient("left").ticks(5);
    yAxis = d3.axisLeft(y);

    // Define the line
    var valueline = d3.line()
        .x(function(d) { return x(d.date); })
        .y(function(d) { return y(d.close); });

    // Adds the svg canvas
    var svg = d3.select("body")
        .append("svg")
        .attr("width", width + margin.left + margin.right)
        .attr("height", height + margin.top + margin.bottom)
        .append("g")
        .attr("transform",
            "translate(" + margin.left + "," + margin.top + ")");

    // Get the data
    d3.csv("data.csv", function(error, data) {
        data.forEach(function(d) {
            d.date = parseDate(d.date);
            d.close = +d.close;
        });

        // Scale the range of the data
        x.domain(d3.extent(data, function(d) { return d.date; }));
        x2.domain(x.domain());
        y.domain([0, d3.max(data, function(d) { return d.close; })]);

        // Add the valueline path.
        svg.append("path")
            .data([data])
            .attr("class", "line")
            .attr("d", valueline);

        // Add the X Axis
        svg.append("g")
            .attr("class", "x axis")
            .attr("transform", "translate(0," + height + ")")
            .call(xAxis);

        // Add the Y Axis
        svg.append("g")
            .attr("class", "y axis")
            .call(yAxis);

    });
    //follow is zoom method------------------
    zoom = d3.zoom()
        .scaleExtent([1, 45])
        .translateExtent([
            [0, 0],
            [width, height]
        ])
        .extent([
            [0, 0],
            [width, height]
        ])
        .on("zoom", zoomed);

    svg.append("rect")
        .attr("class", "zoom")
        .attr("width", width)
        .attr("height", height)
        .attr("transform", "translate(" + margin.left + "," + margin.top + ")")
        .call(zoom);

    function zoomed() {
        let msg = [];
        let t = d3.event.transform;

        msg[0] = t.rescaleX(x2).domain()[0].toString();
        msg[1] = t.rescaleX(x2).domain()[1].toString();

        sendMessage(msg);
    }

    function passiveZoom(rcv){
        let start;
        let end;
        start = new Date(rcv[0]);
        end = new Date(rcv[1]);

        x.domain([start, end]);

        svg.select(".line").attr("d", valueline);
        svg.select(".axis").call(xAxis);
    }



    </script>
</body>

如果您尝试此代码,则应在几个浏览器窗口中执行,并运行此node.js脚本。
var http = require("http");
var socketio = require("socket.io");
var fs = require("fs");

console.log("reflector start");


var server = http.createServer(function(req, res) {
     res.writeHead(200, {"Content-Type":"text/html"});
     var output = fs.readFileSync("./index.html", "utf-8");
     res.end(output);
}).listen(process.env.VMC_APP_PORT || 3000);

var io = socketio.listen(server);

io.sockets.on("connection", function (socket) {

  // send message to all
  socket.on("C_to_S_message", function (data) {
    io.sockets.emit("S_to_C_message", {value:data.value});
       console.log("MSG "+data.value);
  });

  // boradcast send to all without sender
  socket.on("C_to_S_broadcast", function (data) {
    socket.broadcast.emit("S_to_C_broadcast", {value:data.value});
  });

  // disconnection
  socket.on("disconnect", function () {
  console.log("disconnect");
  });
});

1
与您的问题无关:为什么您要计划这个?那些客户端机器由真正的人类用户命令吗?如果是“是”,在我看来,这似乎会提供糟糕的用户体验...(对于一个写得好的问题加1分) - Gerardo Furtado
顺便提一下,x2 是画笔使用的时间刻度,它是图表下部称为“上下文”的部分。您可以看到 x2 域从未改变(但 x 域会改变)。 - Gerardo Furtado
1
谢谢Gerardo。我想在一个网页上同时显示多个时间序列的数据(例如日-人口,日期-GDP等)。每个客户端都会加载<iframe>。 - prophet5
哎呀,打错了。我来修正一下代码。如果原来是“rescaleX(x)”,那么现在改成 "msg[0]= t.rescaleX(x2).domain()[0].toString();" 就好了,这样交互就可以进行缩放了。 - prophet5
进一步探究@Gerardo所启动的问题:为什么要麻烦服务器往返?为什么不直接在客户端上处理,从而减少/避免延迟?别误会,我没有恶意!只是想理解正在发生的事情,也许能学到一些东西;-) - altocumulus
passiveZoom 中更改附加到 SVG 元素的缩放比例,这将调用缩放回调函数,不要传递日期而是缩放参数。 - rioV8
1个回答

5
假设我理解了问题,
(首先)问题在于您没有更新zoom本身。
在使用d3.zoom时,它通常只跟踪当前缩放状态,而不是直接在容器上应用变换。在刷子和缩放示例中,缩放是通过重新缩放数据来实现的 - 而不是通过将SVG变换应用于容器来实现的。使用该示例,我们可以看到当我们刷选时,我们也调用:
svg.select(".zoom").call(zoom.transform, someZoomTransform);

这段代码的作用是:

  • 更新由zoom变量跟踪的缩放状态/身份。
  • 发出缩放事件,该事件会调用缩放函数(在刷选和缩放示例中,如果刷选触发它,则被忽略)。

如果我们删除这行代码,则刷选所做的比例状态更改不会更新缩放。将域值刷选为非常小的区域,然后缩放并在此处查看。

当您使用zoomed函数和d3.event.transform更新图表时,在您的代码中实际上没有更新缩放状态而是更新了比例尺,但是zoom未被更新。

下面我将演示如何使用一个缩放来更新另一个缩放。 注意:如果每个缩放函数都调用其他缩放函数,我们会进入无限循环。通过刷选和缩放,我们可以查看触发器是否为刷选,以查看是否需要传播缩放,下面我使用d3.event.sourceEvent.target来查看其他缩放函数是否需要传播缩放。

var svg = d3.select("svg");
var size = 100;
var zoom1 = d3.zoom().scaleExtent([0.25,4]).on("zoom", zoomed1);
var zoom2 = d3.zoom().scaleExtent([0.25,4]).on("zoom", zoomed2);

var rect1 = svg.append("rect")
  .attr("width", size)
  .attr("height", size)
  .attr("x", 10)
  .attr("y", 10)
  .call(zoom1);
var rect2 = svg.append("rect")
  .attr("width", size)
  .attr("height", size)
  .attr("x", 300)
  .attr("y", 10)
  .call(zoom2);

function zoomed1() {
  var t = d3.event.transform;
  var k = Math.sqrt(t.k);
  rect1.attr("width",size/k).attr("height",size*k);
  
  if(d3.event.sourceEvent.target == this) {
    rect2.call(zoom2.transform,t); 
  }
}
function zoomed2() {
  var t = d3.event.transform;
  var k = Math.sqrt(t.k);
  rect2.attr("width",size/k).attr("height",size*k);
   
  if(d3.event.sourceEvent.target == this) {
    rect1.call(zoom2.transform,t); 
  }
}
rect {
    cursor: pointer;
 stroke: #ccc;
 stroke-width: 10;
  }
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
Zoom on one rectangle to update the other.
<svg width="600" height="300"></svg>

您可能会想为什么我硬编码了大小,而不是修改当前大小,而不是原始大小。答案是缩放变换比例是相对于原始状态的比例-而不是最后的状态。例如,如果每个缩放操作使比例加倍,并且我们缩放2倍,则比例从k=1→k=2→k=4。如果我们将形状的当前大小乘以新比例,我们会得到大小从1→大小=2→大小=8,这是不正确的(并且在缩小到k=2时,我们会将缩放量增加一倍,而不是缩小)。该变换已经是累积性的,我们不想将其应用于具有变换的值。
将变换应用于已经被变换的值,而不是原始值,可能会导致在缩小时增加缩放-这可能就是您在缩小时遇到问题的原因。
这样,我就来到了第二个问题-x2。x2是参考,即原始值。是的,正如Gerardo所指出的那样,它也是您示例中刷子的比例,但更重要的是,他指出这种比例不会改变。因此,x2非常适合用作参考比例,我们可以根据缩放状态使用它来转换x。
x.domain(t.rescaleX(x2).domain()); 

这里发生了什么?transform.rescaleX(x2) 不修改 x2,它“返回一个连续的比例尺 x 的副本,其定义域经过变换[给定缩放变换]. (文档)”。我们使用该副本的定义域并将其分配给 x 比例尺(范围当然保持不变),通过这样做,在 x 比例尺上应用变换。这与我的方形/矩形段落中的代码片段基本相同,我保留了初始形状大小的参考值,并将变换应用于此值。 让我们使用具有比例尺而不是普通形状的基本图表/绘图来查看此操作:

var svg = d3.select("svg");
var data = [[0,300],[1,20],[2,300]];

// Area generators:
var leftArea = d3.area().curve(d3.curveBasis)
  .x(function(d) { return leftX(d[0]); })
  
var rightArea = d3.area().curve(d3.curveBasis)
  .x(function(d) { return rightX(d[0]); })

// Scales
var leftX = d3.scaleLinear().domain([0,2]).range([0,250]);
var rightX = d3.scaleLinear().domain([0,2]).range([300,550]);

var leftX2 = leftX.copy();
var rightX2 = rightX.copy();

// Zooms
var leftZoom = d3.zoom().scaleExtent([0.25,4]).on("zoom", leftZoomed);
var rightZoom = d3.zoom().scaleExtent([0.25,4]).on("zoom", rightZoomed);

// Graphs
var leftGraph = svg.append("path")
  .attr("d", leftArea(data))
  .call(leftZoom);
  
var rightGraph = svg.append("path")
  .attr("d", rightArea(data))
  .call(rightZoom);
  
function leftZoomed() {
  var t = d3.event.transform;
  leftX.domain(t.rescaleX(leftX2).domain());
  leftGraph.attr("d",leftArea(data));
        
  if(d3.event.sourceEvent.target == this) {
    rightGraph.call(rightZoom.transform,t); 
  }
}
function rightZoomed() {
  var t = d3.event.transform;
  rightX.domain(t.rescaleX(rightX2).domain());
  rightGraph.attr("d",rightArea(data));
        
  if(d3.event.sourceEvent.target == this) {
    leftGraph.call(leftZoom.transform,t); 
  }
}
path {
  cursor: pointer;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
    Zoom on one plot to update the other (zoom on the path area itself)
    <svg width="600" height="300"></svg>

简单来说,要在一个页面或跨客户端同步多个可缩放的比例图,您需要:
- 使用 selection.call(zoom.transform, transform) 更新每个缩放。 - 使用当前变换和参考比例尺重新调整每个比例尺。
我还没有深入研究如何在多个客户端和套接字中尝试此操作。但是,上述内容应该有助于解释如何解决问题。但是,在多个客户端中,您可能需要修改我停止缩放事件无限循环的方式,使用或设置变换对象中的属性可能是最简单的。此外,正如 rioV8 所指出的那样,您应该传递缩放参数(或更好的选择是 d3.event 本身),而不是域,虽然仅使用域也是可能的。
对于套接字,我在发送对象方面确实遇到了一些麻烦 - 我不熟悉 socket.io,并且没有花费太多时间查找,但我已经通过以下方式使缩放和被动缩放函数工作:
function zoomed() {
    let t = d3.event.transform;

    // 1. update the scale, same as in brush and zoom:
    x.domain(t.rescaleX(x2).domain());

    // 2. redraw the graph and axis, same as in brush and zoom:
    path.attr("d", area);  // where path is the graph
    svg.select(".xaxis").call(xAxis);

    // 3. Send the transform, if needed:
    if(t.alreadySent == undefined) {
      t.alreadySent = true; // custom property.
      sendMessage([t.k,t.x,t.y,t.alreadySent]);
    }
}

function passiveZoom(rcv){
    // build a transform object (since I was unable to successfully transmit the transform)
    var t = d3.zoomIdentity;
    t.k = rcv[0];
    t.x = rcv[1];
    t.y = rcv[2];
    t.alreadySent = rcv[3];
    //trigger a zoom event (invoke zoomed function with new transform data).
    rect.call(zoom.transform,t);  // where rect is the selection that zoom is called on.
}

不是发送事件,而是仅发送变换参数,并附带一个标志来指示由被动缩放函数触发的缩放事件不需要再次传递。这个原则上与上面的代码片段完全一致。
没有对服务器端脚本进行修改。这是我使用的客户端代码 - 它比你的代码更基础,因为我剥离了y轴、y刻度、csv数据源等内容。链接在此:client side

嗯,我对这个答案还不是很满意:1)我猜你在使用Firefox,对吧?Event.originalTarget只被该浏览器支持。在Chrome中,代码片段无法按预期工作;你会看到一个矩形的缩放,但没有同步。修复很简单,当然要使用Event.target。2)引用的行,具体来说是zoom.transform(),并没有…… - altocumulus
触发缩放事件。3)这更多是一个风格问题(开始挑剔了),但我个人不喜欢someZoomIdentity,因为缩放标识通常用于指代恒等变换[d3.zoomIdentity()](https://github.com/d3/d3-zoom#zoomIdentity)。 - altocumulus
1
@altocumulus 昨晚甚至没有考虑跨平台测试,通过向变换添加属性来修改它以检查传播缩放(稍后会查看缩放目标,现在只是去上班)。对于第二点,我从技术角度没有考虑过这一点,我想可以说zoom.transform发出了一个缩放事件(如文档中所述)?至于第三点,我在语言上犯了错误,已经更新,感谢您的审查,我对第一点感到非常尴尬-尽管其他批评也很好。 - Andrew Reid
你会发现我在第二点上和你在第一点上一样尴尬;-) 我完全忽略了文档中提到的zoom.transform()事件。然而,我建议不要在内部对象上引入新属性。不要使用d3.event.sourceEvent.originalTarget == this,而是使用d3.event.sourceEvent.target == this - altocumulus
1
@prophet5,使用我在这两个片段中使用的技术,我已经成功地在node中使用sockets.io跨浏览器工作了 - 但是我在发送对象时遇到了麻烦,所以变得有些懒惰,只发送了x、y、k并在传输后重新构建了变换 - 更新了我的node/sockets.io解决方案的具体细节(唯一的真正区别是使用被动缩放函数来触发正常的缩放函数以及我如何发送变换,不是作为一个对象,而是作为x、y、k)。 - Andrew Reid
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接