【问题标题】:How to create a 'facetplot' in d3.js?如何在 d3.js 中创建“平面图”?
【发布时间】:2021-04-06 11:48:00
【问题描述】:

假设我有一个数据集,其中包含在不同日期执行相同测试的不同运动员。每天,他们将进行几次试验/运行。我想使用 d3.js 可视化这些天来每位运动员的发展,但我很难理解如何完成这项任务。

在 Python 中使用 seaborn 或在 R 中使用 ggplot2,我会使用 facetplot,其中每一天都是一个方面。在这些方面,我将在 x 轴上进行试验,在 I 轴上进行性能测试。但是如何在 d3.js 中做到这一点?

d3.group and group 允许我按运动员对数据集进行分组,并且我了解如何迭代每个运动员的值。但我不明白如何从这里开始在 d3.js 中实际创建 facetplot。

我曾尝试搜索有关 observable 的相关教程,但运气不佳。一种有趣且相关的可视化是[Trumps Golfs bt Bostock]。另一个is this scatterplot

有人可以帮助我朝着正确的方向前进吗?我已经创建了一个简单的数据集和散点图,可以作为起始点

const data = d3.range(10).map(i => ({
        bib: Math.floor(i / 5) + 1,
        ratio: -1 + Math.random() * 5,
        run: [1, 2, 3, 4, 5][i % 5],
        run: [1, 2, 3, 4, 5][i % 5],
        name: ['GIRL1', 'GIRL2', 'GIRL3', 'GIRL4'][Math.floor(i / 5)]
        }));

        const width = 250;
        const height = 150;
       
        const svg = d3.select('svg')
            .attr('width', width)
            .attr('height', height)

        const margin = {
            top: 20,
            right: 20,
            bottom: 20,
            left: 50
        }

        const innerWidth = width - margin.left - margin.right;
        const innerHeight = height - margin.top - margin.bottom;

        const xScale = d3.scaleLinear()
            .domain(d3.extent(data, d => d.run))
            .range([0, innerWidth])
     
        const yScale = d3.scaleLinear()
            .domain(d3.extent(data, d => d.ratio))
            .range([0, innerHeight])

        const g = svg.append('g')
            .attr('transform', `translate(${margin.left},${margin.top})`)

        g.selectAll('circle')
            .data(data)
            .join('circle')
            .attr('r', 3)
            .attr('cx', d => xScale(d.run))
            .attr('cy', d => yScale(d.ratio))

            g.append("g")
                .call(d3.axisBottom(xScale))
                .attr('transform', `translate(0,${innerHeight})`);;
            
            g.append("g")
                .call(d3.axisLeft(yScale))
<script src="https://unpkg.com/d3@6.2.0/dist/d3.min.js"></script>
<svg></svg>

从 R 中绘制示例:

【问题讨论】:

    标签: javascript d3.js data-visualization


    【解决方案1】:

    设置 SVG

    首先让我们制定一个关于如何对页面进行空间和结构的约定。只有一行图表,这更容易一些。

    我们需要在上图中定义变量。然后我们可以推导出每个图的宽度和高度:

    const height = 500;
    const width = 500;
    const margin = {
       top: 50,
       left: 50,
       right: 50,
       bottom: 50
    }
    const padding = 10; // labelled `pad` in image due to space constraints
    
    const plotWidth = (width-padding)/numberOfPlots - padding;
    const plotHeight = height-padding*2;
    

    当然,你可以随心所欲地安排,我包含这个部分只是为了让其余的答案更容易理解。

    下面我使用g 来保持以边距为界的区域,以便以后定位更容易。

    现在我们还有两种不同的比例,您已经在代码中完成了这项工作,但我们现在可以使用上述间距约定用plotWidthplotHeight 定义范围。

    绘制数据

    现在我们准备好创建绘图了。为此,我们将创建一个嵌套数据集:我们希望按图对数据进行分组,正如您所指出的,我们可以使用 d3.group:

     const grouped = d3.group(data,d=>d.bib);
    

    对于每个分组,我们将创建一个g 并使用基于上述结构的翻译来定位:

     const plots = g.selectAll(".plot")
      .data(grouped)
      .enter()
      .append("g")
      .attr("transform", function(d,i) {
         return "translate("+[i*(padding+plotWidth)+padding,padding]+")";
       })
      
    

    我们已经准备好使用嵌套数据进行绘图了:

       plots.selectAll(null)
         .data(d=>d[1])
         .enter()
         .append("circle")
         ... // and so forth.
    

    下面的 sn-p 使用 d=&gt;d[1],因为这是属于该组的项目数组所在的位置。 d[0] 是组的标识符。如果我的数据数组是一个数组数组,我会简单地使用d=&gt;d;

    现在我们可以在每个图上附加 x 轴:

     plots.append("g")
       .attr("transform","translate("+[0,plotHeight]+")")
       .call(d3.axisBottom(x));
    

    还有一个y轴:

     g.append("g")
       .attr("transform","translate("+[0,padding]+")");
       .call(d3.axisLeft(y));
    

    我没有添加父 x 轴,根据您的使用情况,您可能想要标记它,或者不添加它。我还注意到我按 bib 而非 run 对数据进行分组,但原则保持不变:分组数据,输入并定位父图,然后将数据点添加到父图。

    示例

    // Data and manipluation:
    const data = d3.range(15).map(i => ({
            bib: Math.floor(i / 5) + 1,
            ratio: -1 + Math.random() * 5,
            run: [1, 2, 3, 4, 5][i % 5],
            run: [1, 2, 3, 4, 5][i % 5],
            name: ['GIRL1', 'GIRL2', 'GIRL3', 'GIRL4'][Math.floor(i / 5)]
            }));
            
    const grouped = d3.group(data,d=>d.bib);
    
    
    // Dimensions:
    const height = 500;
    const width = 500;
    const margin = {
           top: 10,
           left: 50,
           right: 50,
           bottom: 50
        }
    const padding = 30; // labelled `pad` in image due to space constraints
    
    const plotWidth = (width-padding)/grouped.size - padding;
    const plotHeight = height-padding*2;
    
    const svg = d3.select("body")
      .append("svg")
      .attr("width", margin.left+width+margin.right)
      .attr("height", margin.top+height+margin.bottom);
      
    const g = svg.append("g")
      .attr("transform","translate("+[margin.left,margin.top]+")");
    
    //Scales:
     const xScale = d3.scaleLinear()
       .domain(d3.extent(data, d => d.run))
       .range([0, plotWidth]);
         
    const yScale = d3.scaleLinear()
       .domain(d3.extent(data, d => d.ratio))
       .range([plotHeight, 0]);
       
    // Place plots:
    const plots = g.selectAll(null)
      .data(grouped)
      .enter()
      .append("g")
      .attr("transform", function(d,i) {
         return "translate("+[i*(padding+plotWidth)+padding,padding]+")";
       })
       
    //Optional plot background:
    plots.append("rect")
      .attr("width",plotWidth)
      .attr("height",plotHeight)
      .attr("fill","#ddd");
       
    
    // Plot actual data
    plots.selectAll(null)
         .data(d=>d[1])
         .enter()
         .append("circle")
         .attr("r", 4)
         .attr("cy", d=>yScale(d.ratio))
         .attr("cx", d=>xScale(d.run))
    
    // Plot line if needed:
    plots.append("path")
      .attr("d", function(d) {
        return d3.line()
                   .x(d=>xScale(d.run))
                   .y(d=>yScale(d.ratio))
                   (d[1])
       })
       .attr("stroke", "#333")
       .attr("stroke-width", 1)
       .attr("fill","none")
       
    // Plot names if needed:
    plots.append("text")
      .attr("x", plotWidth/2)
      .attr("y", -10)
      .text(function(d) {
        return d[1][0].name;
      })
      .attr("text-anchor","middle");
       
    // Plot axes     
     plots.append("g")
       .attr("transform","translate("+[0,plotHeight]+")")
       .call(d3.axisBottom(xScale).ticks(4));
    
    
     g.append("g")
       .attr("transform","translate("+[0,padding]+")")
       .call(d3.axisLeft(yScale))
    &lt;script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.1.0/d3.min.js"&gt;&lt;/script&gt;

    应该产生什么:

    【讨论】:

    • 感谢您抽出宝贵时间创建此分步教程。现在我真的明白该怎么做了。不过有一件事:plots.selectAll(null) 背后的逻辑是什么?那个我没有得到
    • selectAll(null) 确保为空选择,以便为数据数组中的每个项目输入一个元素。由于plots 是空g 元素的选择,我们也可以使用selectAll("circle") 或任何选择器。由于在 g 元素中没有可供选择的内容,因此我们将始终有一个空选择。
    【解决方案2】:

    今天第二次@AndrewReid 打败了我,当然他的回答非常完美(我喜欢这个图表)。

    不管这是我的条目。最大的不同是我使用d3.scaleBand(和数据绑定)来放置每个子折线图,而他更多地手动完成)。

    <!DOCTYPE html>
    
    <html>
      <head>
        <script src="https://d3js.org/d3.v6.min.js"></script>
        <style>
           svg {
            font-family: arial
          }
          .tick line {
            stroke: white;
            stroke-opacity: 0.7;
            shape-rendering: crispEdges;
          }
        </style>
      </head>
    
      <body>
        <svg></svg>
        <script>
          let data = [],
            tests = ['PRETEST', 'TRENING1', 'TRENING2', 'TRENING3', 'POSTTEST'],
            courses = ['COURSE 1', 'COURSE 2', 'COURSE 3', 'STRAIGHT-GLIDING'];
    
          tests.forEach((i) => {
            courses.forEach((j) => {
              d3.range(5).map((k) => {
                data.push({
                  test: i,
                  course: j,
                  run: k,
                  ratio: -1 + Math.random() * 5,
                });
              });
            });
          });
    
          const width = 1100;
          const height = 350;
    
          const margin = {
            top: 20,
            right: 10,
            bottom: 20,
            left: 30,
          };
    
          // place wrapper g with margins
          const svg = d3
            .select('svg')
            .attr('width', width)
            .attr('height', height)
            .append('g')
            .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
    
          // calculate the outer scale band for each line graph
          const outerXScale = d3
            .scaleBand()
            .domain(tests)
            .range([0, width - margin.left - margin.right]);
    
          // inner dimensions of chart based on bandwidth of outer scale
          const innerWidth = outerXScale.bandwidth() - margin.left - margin.right;
          const innerHeight = height - margin.top - margin.bottom;
    
          // g for each inner chart
          const testG = svg
            .selectAll('.outer')
            .data(d3.group(data, (d) => d.test))
            .enter()
            .append('g')
            .attr('class', 'outer')
            .attr('transform', function (d, i) {
              return 'translate(' + outerXScale(d[0]) + ',' + 0 + ')';
            });
    
          // some styling
          testG
            .append('rect')
            .attr('width', innerWidth)
            .attr('height', innerHeight)
            .attr('fill', '#f2f2f2');
    
          testG
            .append('rect')
            .attr('width', innerWidth)
            .attr('height', 17)
            .attr('transform', 'translate(' + 0 + ',' + -17 + ')')
            .attr('fill', '#e6e6e6');
    
          // header
          testG
            .append('text')
            .text(function (d) {
              return d[0];
            })
            .attr('text-anchor', 'middle')
            .attr('transform', 'translate(' + innerWidth / 2 + ',' + -2 + ')');
    
          // inner scales
          const innerXScale = d3
            .scaleLinear()
            .domain(d3.extent(data, (d) => d.run))
            .range([0, innerWidth]);
    
          const innerYScale = d3
            .scaleLinear()
            .domain(d3.extent(data, (d) => d.ratio))
            .range([innerHeight, 0]);
    
          testG
            .append('g')
            .call(d3.axisBottom(innerXScale).tickSize(-innerHeight))
            .attr('transform', `translate(0,${innerHeight})`);
    
          testG.append('g').call(d3.axisLeft(innerYScale).tickSize(-innerWidth));
    
          testG
            .selectAll('.line')
            .data(function (d) {
              return d3.group(d[1], (d) => d.course);
            })
            .enter()
            .append('path')
            .attr('d', function (d) {
              return d3
                .line()
                .x((d) => innerXScale(d.run))
                .y((d) => innerYScale(d.ratio))(d[1]);
            })
            .attr('fill', 'none')
            .attr('stroke', function (d, i) {
              return d3.schemeCategory10[i];
            })
            .attr('stroke-width', 1);
        </script>
      </body>
    </html>

    【讨论】:

    • 哈,哎呀,看到你的评论,等了一会儿。我猜还不够。如果 SO 说谁已经开始回答会很好,这周我会节省一些时间。从好的方面来说,如果我们继续回答同样的问题,也许我可以获得体育道德徽章。
    • @AndrewReid,如果我踩到了你的脚趾,请不要担心和道歉。实际上,我一直很喜欢看到我们的微妙不同的方法。
    • 没有脚趾被踩,总是喜欢看到你的答案,我也喜欢能够比较我们不同的方法。
    猜你喜欢
    • 1970-01-01
    • 2018-06-17
    • 2019-03-09
    • 2015-05-10
    • 1970-01-01
    • 2015-09-23
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多