Wendy Shijia 的「 Escher's Gallery」可视化作品复现系列文章(三)

354 阅读5分钟

网页演示:desertsx.github.io/dataviz-in-…
开源代码(可点 Star 支持):DesertsX/dataviz-in-action

Wendy Shijia 的「 Escher's Gallery」可视化作品复现系列文章(一)
Wendy Shijia 的「 Escher's Gallery」可视化作品复现系列文章(二)

通过前两篇文章,古柳拼凑出了一个 cube,并且构造伪数据将整体布局效果大致搞定,在第二篇文章最后古柳给出了更优雅的、和 Wendy 原始方式一致的 unit/cube 实现代码,不过新的实现在后续尺寸和布局上无法完全替换旧的实现,"牵一发而动全身",已开源的代码需要较多改动,因而只能暂时按最初的实现来讲解复现过程,感兴趣的可以自行基于新的实现来修改。

书接上文,用伪数据搞定布局后,就该替换成真实数据了,其实想想 Wendy 的作品发布在 tableau public 上,仔细找下应该也会有数据集,但没准需要下载 tableau 就有些麻烦,想着去原始网站爬取应该也不难,就采取了写个 Python 爬虫自行爬取的方案。
链接:public.tableau.com/profile/wen…

当然爬虫不是重点,爬取的数据也开源了,大家直接关注可视化部分即可,这里简单看下源网站页面结构/数据情况:下图分别是一个包含470个作品的列表页和其中1个作品的详情页,抽取出相应数据即可。

存储的数据格式如下,挺好懂,就不多余解释了。

[
  {
    "id": 0,
    "url": "https://www.wikiart.org/en/m-c-escher/bookplate-bastiaan-kist",
    "img": "https://uploads4.wikiart.org/images/m-c-escher/bookplate-bastiaan-kist.jpg",
    "title": "Bookplate Bastiaan Kist",
    "date": "1916",
    "style": "Surrealism",
    "genre": "symbolic painting"
  },
  ...
]

接下来就是本次复现的代码部分,习惯看源码的可直接去 GitHub 里阅读即可。

开源代码(可点 Star 支持):DesertsX/dataviz-in-action

虽然古柳也不喜欢在文章里大段大段贴代码片段,但还是有必要简单讲解下,自然看到这篇文章的读者背景/基础可能都不同,一定会有不少人不一定能完全看懂,本系列也并非 D3.js 入门教程,所以可能无法顾及所有读者,虽然并没有过于深奥的地方,但若是有疑惑可评论或群里交流。

首先用的是 D3.js v5 版本,由于用到 d3.rollup() 方法,需要另外引入 d3-array.v2.min.js,如果用最新的 D3.js v6 版本就无需另外引入后者了。

<script src="../d3.js"></script>
<script src="https://d3js.org/d3-array.v2.min.js"></script>

HTML 页面结构并不复杂,主要是整个图表 svg 部分加上交互显示每件作品信息时的 tooltip。其中 svg 里放了上篇文章里实现的不太优雅的三个 unit 多边形,后续用 D3.js 绘图时通过生成 use 标签分别进行调用即可。

<body>
    <div id="container">
        <div id="main">
            <svg id="chart" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
                <defs>
                    <polygon id="unit-0" points="0,0 16,0 16,16 32,16 32,32 0,32"
                        transform="scale(1.4,.8) rotate(-45) translate(-20, 132.3)" />
                    <polygon id="unit-1" points="0,0 32,0 32,32 16,32 16,16 0,16"
                        transform="skewY(30) translate(111, 22)" />
                    <polygon id="unit-2" points="0,0 32,0 32,16 16,16 16,32 0,32"
                        transform="skewY(-30) translate(143, 187)" />
                </defs>
            </svg>
        </div>

        <div id="tooltip" class="tooltip">
            <div class="tooltip-title" id="title"></div>
            <div class="tooltip-date" id="date"></div>
            <div class="tooltip-type" id="type">
                <span id='style'></span> | <span id='genre'></span>
            </div>
            <div class="tooltip-image" id="image"><img alt=""></div>
            <div class="tooltip-url" id="url"><a target="_blank">go to link</a></div>
        </div>
    </div>
    <script src="./app.js"></script>
</body>

app.js 里就是所有实现代码,且都写在了 drawChart() 里。读取数据并对 date 年份以及作品类型进行处理。

async function drawChart() {
  const data = await d3.json("./data.json");

  const svg = d3.select("#chart");
  const bounds = svg.append("g");

  // console.log([...new Set(data.map((d) => d.style))]);
  // ["Surrealism", "Realism", "Expressionism", "Cubism", "Op Art", "Art Nouveau (Modern)", "Northern Renaissance", "Art Deco"]
  data.map((d) => {
    d.date = d.date !== "?" ? +d.date : "?";
    d.style = d.style === "Op Art" ? "Optical art" : d.style;
    d.style2 = ["Surrealism", "Realism", "Expressionism", "Cubism", "Optical art"].includes(d.style) ? d.style : "Other";
  });
  console.log(data);

  const colorScale = {
    "Optical art": "#ffc533",
    Surrealism: "#f25c3b",
    Expressionism: "#5991c2",
    Realism: "#55514e",
    Cubism: "#5aa459",
    Other: "#bdb7b7",
  };
  
  // more...

}

drawChart();

style2 作品类型会通过 colorScale() 和颜色相对应,styleCount 会用于 drawStyleLegend() 绘制类型图例。这里用 d3.rollup() 统计各类型的数量,其它实现方式亦可。 链接:observablehq.com/@d3/d3-grou…

  const styleCountMap = d3.rollup(
    data,
    (v) => v.length,
    (d) => d.style2
  );
  // console.log("styleCount :", styleCountMap);
  const styleCount = [];
  for (const [style, count] of styleCountMap) {
    styleCount.push({ style, count });
  }
  // console.log(styleCount);
  // drawStyleLegend() 里会用到

既然讲到了图例,就先看看类型图例的实现,很常规的 D3.js 绘图的内容。

  // style bar chart
  function drawStyleLegend() {
    const countScale = d3
      .scaleLinear()
      .domain([0, d3.max(styleCount, (d) => d.count)])
      .range([0, 200]);

    const legend = bounds.append("g").attr("transform", "translate(1000, 40)");

    const legendTitle = legend
      .append("text")
      .text("Number of artworks by style")
      .attr("x", 20)
      .attr("y", 10);

    const legendGroup = legend
      .selectAll("g")
      .data(styleCount.sort((a, b) => b.count - a.count))
      .join("g")
      .attr("transform", (d, i) => `translate(110, ${28 + 15 * i})`);

    const lengedStyleText = legendGroup
      .append("text")
      .text((d) => d.style) // this's style2
      .attr("x", -90)
      .attr("y", 6)
      .attr("text-anchor", "start")
      .attr("fill", "grey")
      .attr("font-size", 11);

    const lengedRect = legendGroup
      .append("rect")
      .attr("width", (d) => countScale(d.count))
      .attr("height", 8)
      .attr("fill", (d) => colorScale[d.style]);

    const lengedStyleCountText = legendGroup
      .append("text")
      .text((d) => d.count)
      .attr("x", (d) => countScale(d.count) + 10)
      .attr("y", 8)
      .attr("fill", (d) => colorScale[d.style])
      .attr("font-size", 11);
  }

  drawStyleLegend();

当然实在不想自己从头绘制图例,也可以用 Susie Lud3 SVG Legend (v4) 库。

接着,通过 getXY() 函数返回作品 unit 布局时会用到的组内顺序、列数、行数,在上一篇文章Wendy Shijia 的「 Escher's Gallery」可视化作品复现系列文章(二)里已经有过介绍,基本相同。

  const getXY = (idx) => {
    let col;
    let row;
    if (idx < 14) {
      col = 1;
      row = parseInt((idx % 24) / 3) + 1;
      groupIdx = idx;
    } else if (idx < 99) {
      groupIdx = idx - 14;
      col = 1 + parseInt(groupIdx / 24) + 1;
      row = parseInt((groupIdx % 24) / 3) + 1;
    } else if (idx < 273) {
      groupIdx = idx - 99;
      col = 5 + parseInt(groupIdx / 24) + 1;
      row = parseInt((groupIdx % 24) / 3) + 1;
    } else if (idx < 335) {
      groupIdx = idx - 273;
      col = 13 + parseInt(groupIdx / 24) + 1;
      row = parseInt((groupIdx % 24) / 3) + 1;
    } else if (idx < 416) {
      groupIdx = idx - 335;
      col = 16 + parseInt(groupIdx / 24) + 1;
      row = parseInt((groupIdx % 24) / 3) + 1;
    } else if (idx < 457) {
      groupIdx = idx - 416;
      col = 20 + parseInt(groupIdx / 24) + 1;
      row = parseInt((groupIdx % 24) / 3) + 1;
    } else {
      groupIdx = idx - 457;
      col = 22 + parseInt(groupIdx / 24) + 1;
      row = parseInt((groupIdx % 24) / 3) + 1;
    }
    return [groupIdx, col, row];
  };

通过 drawArtwork() 函数生成所有作品的 use 标签,调用 defs 标签里的 unit,结合 getXY() 函数传入正确的x/y坐标及 unit id,绘制出图表主体的内容即可。注意每列高度隔行相等,简单处理下即可。

  const cubeWidth = 32;
  //  2%3=2  parseInt(4/3)=1  or Math.floor(4/3)
  const artworkGroup = bounds
    .append("g")
    .attr("class", "main-chart")
    .attr("transform", `scale(1.12)`);

  function drawArtwork() {
    const artworks = artworkGroup
      .selectAll("use.artwork")
      .data(data)
      .join("use")
      .attr("class", "artwork")
      .attr("xlink:href", (d, i) =>
        getXY(i)[0] % 3 === 0
          ? "#unit-0"
          : getXY(i)[0] % 3 === 1
          ? "#unit-1"
          : "#unit-2"
      )
      .attr("fill", (d) => colorScale[d.style2])
      .attr("stroke", "white")
      .attr("data-index", (d) => d.style2)
      .attr("id", (d, i) => i)
      .attr("x", (d, i) => getXY(i)[1] * 1.5 * cubeWidth - 80)
      .attr(
        "y",
        (d, i) =>
          110 +
          getXY(i)[2] * 1.5 * cubeWidth +
          (getXY(i)[1] % 2 === 0 ? 0 : 0.75 * cubeWidth)
      );
  }

  drawArtwork();

接着加上背景的空白 cube,古柳复现时还原了原作这部分效果,虽然可加可不加,偷个懒也没事,但一开始觉得没准这部分和埃舍尔的艺术风格有关,于是还是加上了。后来看 Wendy 关于该可视化作品的分享 「VizConnect - Drawing Polygons in Tableau: The processing of making Escher's Gallary」,从中了解到背景这部分是最后才加上的,大概是 Wendy 觉得每组之间有空隙所以加上背景纹理进行填充。

构造需要添加空白 unit 的数据,blankData 数据分成两部分,一部分是每列上方和下方完整的那些 cube,即 d3.range(1, 24).map() 里遍历的那些 x/y 行列位置,重复3次把3个 unit 都列出来,其中 rawMax 是每列的 cube 数、每列上方起始位置隔列不同、每列下方根据 rawMax 里对应的值把剩余的空白位置填满即可;另一部分是每组年龄段最后一个 cube 可能需要另外补充的那些 unit ,可通过 specialBlank 列举出所有特殊情况。最后同样生成 use 标签以绘制出空白 unit 即可。

这里的实现不一定是最好的,可按照自己的思路实践,仅供参考。

  function drawBlankArtwork() {
    // bottom odd 9 / even 10
    const rawMax = [5, 8, 8, 8, 5, 8, 8, 8, 8, 8, 8, 8, 2, 8, 8, 5, 8, 8, 8, 3, 8, 6, 5, ];
    // console.log(rawMax.length); // 23
    const blank = [];
    d3.range(1, 24).map((d) => {
      // top odd 0/-1 / even 0
      d % 2 === 0
        ? blank.push({ x: d, y: 0 })
        : blank.push({ x: d, y: 0 }, { x: d, y: -1 });
      // bottom odd 9 / even 10
      if (d % 2 === 0) {
        for (let i = rawMax[d - 1] + 1; i <= 10; i++)
          blank.push({ x: d, y: i });
      } else {
        for (let i = rawMax[d - 1] + 1; i <= 9; i++) blank.push({ x: d, y: i });
      }
    });

    let blankData = [];
    blank.map((d) => {
      // repeat 3 times
      d3.range(3).map(() => blankData.push({ x: d.x, y: d.y }));
    });
    const specialBlank = [
      { x: 1, y: 5, unit: 2 },
      { x: 5, y: 5, unit: 1 },
      { x: 5, y: 5, unit: 2 },
      { x: 16, y: 5, unit: 2 },
      { x: 22, y: 6, unit: 2 },
      { x: 23, y: 5, unit: 1 },
      { x: 23, y: 5, unit: 2 },
    ];
    blankData = [...blankData, ...specialBlank];

    const blankArtworks = artworkGroup
      .selectAll("use.blank")
      .data(blankData)
      .join("use")
      .attr("class", "blank")
      .attr("xlink:href", (d, i) =>
        d.unit
          ? `#unit-${d.unit}`
          : i % 3 === 0
          ? "#unit-0"
          : i % 3 === 1
          ? "#unit-1"
          : "#unit-2"
      )
      .attr("fill", "#f2f2e8")
      .attr("stroke", "white")
      .attr("stroke-width", 1)
      .attr("x", (d) => d.x * 1.5 * cubeWidth - 80)
      .attr(
        "y",
        (d) =>
          110 + d.y * 1.5 * cubeWidth + (d.x % 2 === 0 ? 0 : 0.75 * cubeWidth)
      );
  }

  drawBlankArtwork();

然后每组加上文字信息。

  function drawDateInfo() {
    const dateText = [
      { col: 1, shortLine: false, age: "age<20", range: "1898-" },
      { col: 2, shortLine: true, age: "20-29", range: "1918-1927" },
      { col: 6, shortLine: true, age: "30-39", range: "1928-1937" },
      { col: 14, shortLine: true, age: "40-49", range: "1938-1947" },
      { col: 17, shortLine: false, age: "50-59", range: "1948-1957" },
      { col: 21, shortLine: false, age: "60-69", range: "1958-1972" },
      { col: 23, shortLine: false, age: "", range: "Year Unknown" },
    ];
    const dateTextGroup = artworkGroup.selectAll("g").data(dateText).join("g");

    dateTextGroup
      .append("text")
      .text((d) => d.age)
      .style("text-anchor", "start")
      .attr("x", (d, i) => d.col * 1.5 * cubeWidth + (i === 0 ? 34 : 42))
      .attr("y", 195)
      .attr("font-size", 13);

    dateTextGroup
      .append("text")
      .text((d) => d.range)
      .style("text-anchor", "start")
      .attr("x", (d, i) => d.col * 1.5 * cubeWidth + (i === 6 ? 30 : 35))
      .attr("y", 210)
      .attr("fill", "grey")
      .attr("font-size", 11);

    dateTextGroup
      .append("line")
      .attr("x1", (d, i) => d.col * 1.5 * cubeWidth + 63)
      .attr("x2", (d, i) => d.col * 1.5 * cubeWidth + 63)
      .attr("y1", 215)
      .attr("y2", (d) => (d.shortLine ? 246 : 270))
      .attr("stroke", "#2980b9")
      .attr("stroke-dasharray", "1px 1px");
  }

  drawDateInfo();

然后把标题、下方文字描述等剩余部分都加上即可,都是些细枝末节的工作了,没啥难度看源码即可,这里就不放了。需要说明的是下方文字内容原本古柳用 HTML+CSS 实现,但可能太菜总感觉效果不理想,最后也还是用 D3.js SVG text 等各种拼接出来,也不够优雅、略显冗余。

最后是加上交互,点击每个 unit 时显示相应作品数据,点击 svg 其余区域时隐藏 tooltip。交互也很简陋,有改进空间。

  const tooltip = d3.select("#tooltip");

  svg.on("click", displayTooltip);

  function displayTooltip() {
    tooltip.style("opacity", 0);
  }

  d3.selectAll("use.artwork").on("click", showTooltip);

  function showTooltip(datum) {
    tooltip.style("opacity", 1);
    tooltip.select("#title").text(datum.title);
    tooltip
      .select("#date")
      .text(datum.date !== "?" ? datum.date : "Year Unknown");
    tooltip.select("#style").text(datum.style);
    tooltip.select("#genre").text(datum.genre);
    tooltip.select("#image img").attr("src", datum.img);
    tooltip.select("#url a").attr("href", datum.url);

    let [x, y] = d3.mouse(this);
    x = x > 700 ? x - 300 : x;
    y = y > 450 ? y - 300 : y;
    tooltip.style("left", `${x + 100}px`).style("top", `${y + 50}px`);

    d3.event.stopPropagation();
  }

以上就是本文全部内容,真的只是简单的讲下一些要点,其实大家只要大致知道实现的思路,就完全可以靠自己的理解去复现了,古柳的复现代码也有很多不足,仅供参考,仍有困惑的可以评论或群里交流。

照例

欢迎加入可视化交流群哈。可加古柳微信「xiaoaizhj」备注「可视化加群」拉你进群哈!

最后,欢迎关注古柳的公众号「牛衣古柳」,以便第一时间收到更新。