React&D3的美味食谱。排名栏。
#javascript #网络开发人员 #react #d3

介绍

我和我的同事和我遇到的情况需要在前端项目期间实现自定义的视觉解决方案 - 这包括图表,图表和交互式方案等各种任务。在一个项目中,我只需要处理图表,并且能够使用免费图表库快速有效地解决问题。但是,在下一个项目中,我可以选择采用哪种方法以及要使用的库。在进行了一些研究并从权威来源寻求建议之后,我确定D3库是最佳解决方案,原因有三个主要原因。

  1. 灵活性。尽管有许多流行的现有模式,但D3允许我们提供任何基于SVG的图形。
  2. 受欢迎程度。该库是最常用的库之一。它有一个庞大的社区和大量的学习资源。
  3. 普遍性。基于数据的不同图表和可视化有许多现有模式。此外,它支持JSON和CSV等各种数据格式。

尽管D3受欢迎,但在研究期间我遇到了一些困难,促使我写这篇文章。我想帮助我的同事在类似的情况下导航。

值得注意的是,我前面提到的所有项目都是基于React的,因此我将提供的所有代码示例也将连接到React。我不想专注于无关的主题并旨在提供简约的解决方案,这就是为什么我将使用JavaScript而不是Typescript。

排名条任务。

如前所述,我的目标是提供快速,易于使用的解决方案,即使它们很小并且没有立即引起注意。这就是为什么我创建了一系列简单示例,这些示例演示了如何使用D3创建简单的反应排名bar组件。

现在,让我们专注于几个要点。

我们有什么。

我们将以下类型的数据水果作为具有相应值的密钥。

const data = {
  Apple: 100,
  Apricot: 200,
  Araza: 5,
  Avocado: 1,
  Banana: 150,
  Bilberry: 700,
  // ...
  Feijoa: 11,
  Fig: 0,
};

我们期望的。

我们期望具有以下功能的简单可视化:

  1. 所有的酒吧(水果)应从最大值订购至最小的。
  2. 如果可能的话,所有条应包含相关的水果名。如果水果名宽度小于条宽度,则应裁剪名称并添加或隐藏。
  3. 该组件应具有响应性。如果用户更改屏幕大小,则应重新绘制组件。

alt-text

步骤#1:入门

我想跳过项目设置并直接关注代码,尤其是因为我将提供下面的所有工作示例。在我的第一步中,我将提供一个基于空的SVG组件。

我们的应用程序组件应该看起来像这样...

import React from "react";
import StackedRank from "./StackedRank";
import "./style.css";

export default function App() {
  return (
    <div id="root-container">
      <StackedRank />
    </div>
  );
}

注意属性id="root-container"。这是一个图表容器,我们将在StackedRank组件内使用。

让我们看一下StackedRank组件。

import React, { useEffect, useState, useRef } from "react";
import * as d3 from "d3";

export default function StackedRank() {
  const svgRef = useRef();
  const [width, setWidth] = useState();
  const [height, setHeight] = useState();

  const recalculateDimension = () => {
    const getMaxWidth = () =>
      parseInt(
        d3.select("#root-container")?.node()?.getBoundingClientRect()?.width ??
          100,
        10
      );
    setWidth(getMaxWidth());
    setHeight(50);
  };

  const renderSvg = () => {
    const svg = d3.select(svgRef.current);

    svg
      .append("rect")
      .attr("x", 0)
      .attr("width", width)
      .attr("y", 0)
      .attr("height", height)
      .attr("fill", "grey");
  };

  useEffect(() => {
    recalculateDimension();
  }, []);

  useEffect(() => {
    if (width && height) {
      renderSvg();
    }
  }, [width, height]);

  if (!width || !height) {
    return <></>;
  }

  return <svg ref={svgRef} width={width} height={height} />;
}

您可以在StackBlitz, link 1.上找到完整的解决方案

让我解释有关上述代码的一些重要点。首先,我们需要处理组件容器和形状。默认情况下,图表宽度和高度不确定。

const [width, setWidth] = useState();
const [height, setHeight] = useState();

这就是为什么我们需要使用以下代码设置它们:

useEffect(() => {
  recalculateDimension();
}, []);
const recalculateDimension = () => {
  const getMaxWidth = () =>
    parseInt(
      d3.select("#root-container")?.node()?.getBoundingClientRect()?.width ??
        100,
      10
    );
  setWidth(getMaxWidth());
  setHeight(50);
};

在上面的代码中,我们计算使用父容器root-container适合可用屏幕宽度的组件宽度。高度应固定(50px)。

另外,特别注意以下代码:

if (!width || !height) {
  return <></>;
}

return <svg ref={svgRef} width={width} height={height} />;

首先,我们以SVG格式显示我们的图形内容。其次,如果其形状不确定,我们不应该显示它。

useEffect(() => {
  if (width && height) {
    renderSvg();
  }
}, [width, height]);

定义组件形状时,让我们处理图形内容。

以下代码

const renderSvg = () => {
  const svg = d3.select(svgRef.current);

  svg
    .append("rect")
    .attr("x", 0)
    .attr("width", width)
    .attr("y", 0)
    .attr("height", height)
    .attr("fill", "grey");
};

只是根据组成形状绘制灰色矩形。

这就是步骤#1。

步骤#2:React组件的主要功能

此步骤的主要目标是使StackedRank组件成为堆叠的等级图表,对重言式的歉意。因此,我们需要绘制以下

alt-text

而不仅仅是灰色矩形。

相关代码更改在Stackblitz, link 2.

我们需要做的第一件事是定义应用程序组件中的数据并将其传递到图表组件。

const data = {
  Apple: 100,
  Apricot: 200,
  Araza: 5,
  Avocado: 1,
  Banana: 150,
  // ...
  Durian: 20,
  Elderberry: 35,
  Feijoa: 11,
  Fig: 0,
};

export default function App() {
  return (
    <div id="root-container">
      <StackedRank data={data} />
    </div>
  );
}

传统上,我想提供完整的组件代码并在此之后对其进行评论。

import React, { useEffect, useState, useRef } from "react";
import * as d3 from "d3";

function getNormalizedData(data, width) {
  const tmpData = [];
  let total = 0;
  for (const key of Object.keys(data)) {
    if (data[key] > 0) {
      tmpData.push({ fruit: key, value: data[key] });
      total += data[key];
    }
  }
  tmpData.sort((a, b) => b.value - a.value);
  let x = 0;
  for (const record of tmpData) {
    const percent = (record.value / total) * 100;
    const barwidth = (width * percent) / 100;
    record.x = x;
    record.width = barwidth;
    x += barwidth;
  }
  return tmpData;
}

export default function StackedRank({ data }) {
  const svgRef = useRef();
  const [normalizedData, setNormalizedData] = useState();
  const [width, setWidth] = useState();
  const [height, setHeight] = useState();

  const recalculateDimension = () => {
    const getMaxWidth = () =>
      parseInt(
        d3.select("#root-container")?.node()?.getBoundingClientRect()?.width ??
          100,
        10
      );
    setWidth(getMaxWidth());
    setHeight(50);
  };

  const renderSvg = () => {
    const svg = d3.select(svgRef.current);

    const color = d3
      .scaleOrdinal()
      .domain(Object.keys(normalizedData))
      .range(d3.schemeTableau10);

    svg
      .selectAll()
      .data(normalizedData)
      .enter()
      .append("g")
      .append("rect")
      .attr("x", (d) => d.x)
      .attr("width", (d) => d.width - 1)
      .attr("y", 0)
      .attr("height", 50)
      .attr("fill", (_, i) => color(i));

    svg
      .selectAll("text")
      .data(normalizedData)
      .join("text")
      .text((d) => d.fruit)
      .attr("x", (d) => d.x + 5)
      .attr("y", (d) => 30)
      .attr("width", (d) => d.width - 1)
      .attr("fill", "white");
  };

  useEffect(() => {
    recalculateDimension();
  }, []);

  useEffect(() => {
    if (normalizedData) {
      renderSvg();
    }
  }, [normalizedData]);

  useEffect(() => {
    if (width && height && data) {
      setNormalizedData(getNormalizedData(data, width));
    }
  }, [data, width, height]);

  if (!width || !height || !normalizedData) {
    return <></>;
  }

  return <svg ref={svgRef} width={width} height={height} />;
}

此步骤中最乏味,最耗时的部分是数据转换,该数据转换包含在“ getNormalizedData”函数中。我不想详细解释。此功能的主要目的是:

  1. 提供更方便的数据表示形式 - 一个对象数组而不是一个对象。
  2. 包含UI耗尽的数据:栏的X和宽度。

注意以下几行:

const percent = (record.value / total) * 100;
const barwidth = (width * percent) / 100;

应根据果实的总价值和组分宽度来计算每个条的宽度。

另外,我建议使用我的示例进行调试或“ console.log”此代码:Stackblitz, link 2 - StackedRanked.jsx

步骤#2的组件代码具有不同的初始化逻辑。

useEffect(() => {
  recalculateDimension();
}, []);

useEffect(() => {
  if (normalizedData) {
    renderSvg();
  }
}, [normalizedData]);

useEffect(() => {
  if (width && height && data) {
    setNormalizedData(getNormalizedData(data, width));
  }
}, [data, width, height]);

让我将上面的React代码转换为可读形式。首先,我们计算组件尺寸。一旦拥有它们,我们将数据正常化,因为我们现在有足够的信息。最后,使用归一化数据,我们使用D3渲染SVG。现在,我们准备专注于渲染。

如下所示,我们的渲染由四个部分组成。请在代码中阅读我的评论。如果您不太熟悉D3,请不要担心。虽然本文的目的不是教D3,但我想为您提供一些重要的D3特定实施。

const renderSvg = () => {
  // "Associate" `svg` varable with svgRef:
  // return <svg ref={svgRef} width={width} height={height} />;
  const svg = d3.select(svgRef.current);

  // Get the list of colors using D3-way
  const color = d3
    .scaleOrdinal()
    // Apple, Apricot, Araza, Avocado, etc
    .domain(Object.keys(normalizedData))
    .range(d3.schemeTableau10);

  // Draw all expected bars according to `normalizedData`
  svg
    .selectAll()
    // connect our data here
    .data(normalizedData)
    .enter()
    // now we are ready for drawing
    .append("g")
    // draw the rect
    .append("rect")
    // `d` variable represents an item of normalizedData
    // that we connected before
    // please, also look at `getNormalizedData`
    // we need to take x and width from there
    .attr("x", (d) => d.x)
    .attr("width", (d) => d.width - 1)
    .attr("y", 0)
    .attr("height", 50)
    // Color for the bar depends only on its order `i`
    .attr("fill", (_, i) => color(i));

  // Put texts over all related bars according to `normalizedData`
  svg
    // we need to work with text
    .selectAll("text")
    .data(normalizedData)
    // we need to work with text
    .join("text")
    // because `d` variable represents an item of normalizedData
    // we can take the related fruit name from there
    .text((d) => d.fruit)
    // set x, y, and color
    .attr("x", (d) => d.x + 5)
    .attr("y", (d) => 30)
    .attr("fill", "white");
    // also, you can set more attributes like Font Family, etc...
};

如果上面的评论不足以完全了解该主题,我强烈建议阅读其他D3资源。此外,我认为来自Stackblitz,Codepen等的实施例子将有助于理解D3原则。

现在,经过漫长的解释,让我们看一下示例的工作原理。

alt text

它看起来可以预见,但有点丑陋。我们需要处理重叠的文本。另外,该组件应响应迅速。如果用户更改屏幕大小,则应重新绘制组件。

步骤#3:响应能力和智能水果

一如既往,我想先提供完整的代码。 Stackblitz, link 3

import React, { useEffect, useState, useRef } from 'react';
import * as d3 from 'd3';
import { dotme, useWindowSize } from './utils';

function getNormalizedData(data, width) {
    // let's skip it because
    // this implementation hasn't changed comparing
    // with the previous implementation
}

export default function StackedRank({ data }) {
  const svgRef = useRef();
  const [fullWidth, fullHeight] = useWindowSize();
  const [normalizedData, setNormalizedData] = useState();
  const [width, setWidth] = useState();
  const [height, setHeight] = useState();

  const recalculateDimension = () => {
    // let's skip it because
    // this implementation hasn't changed comparing
    // with the previous implementation
  };

  const renderSvg = () => {
    const svg = d3.select(svgRef.current);

    svg.selectAll('*').remove();

    const color = d3
      .scaleOrdinal()
      .domain(Object.keys(normalizedData))
      .range(d3.schemeTableau10);

    svg
      .selectAll()
      .data(normalizedData)
      .enter()
      .append('g')
      .append('rect')
      .attr('x', (d) => d.x)
      .attr('width', (d) => d.width - 1)
      .attr('y', 0)
      .attr('height', 50)
      .attr('fill', (_, i) => color(i));

    svg
      .selectAll('text')
      .data(normalizedData)
      .join('text')
      .text((d) => d.fruit)
      .attr('x', (d) => d.x + 5)
      .attr('y', (d) => 30)
      .attr('width', (d) => d.width - 1)
      .attr('fill', 'white');

    svg.selectAll('text').call(dotme);
  };

  useEffect(() => {
    if (normalizedData) {
      renderSvg();
    }
  }, [normalizedData]);

  useEffect(() => {
    if (width && height) {
      setNormalizedData(getNormalizedData(data, width));
    }
  }, [width, height]);

  useEffect(() => {
    if (data) {
      recalculateDimension();
    }
  }, [data, fullWidth, fullHeight]);

  if (!width || !height || !normalizedData) {
    return <></>;
  }

  return <svg ref={svgRef} width={width} height={height} />;
}

响应能力

尽管固定的组件高度(50px),我们仍需要根据每个窗口调整大小的可用屏幕宽度来重新计算其宽度。这就是为什么我添加了一个新钩子。钩子是useWindowSize。您可以在此处找到相关来源Stackblitz, link 3 - StackedRank.jsx

让我强调有关责任的基本要点。

  const [fullWidth, fullHeight] = useWindowSize();

获取可用的屏幕尺寸全宽,全力。

  useEffect(() => {
    if (data) {
      recalculateDimension();
    }
  }, [data, fullWidth, fullHeight]);

如果屏幕更改,请重新计算组件大小。

聪明的水果

在讨论智能文本之前,我建议您查看以下解决方案:https://codepen.io/nixik/pen/VEZwYd。这很重要,因为我将DOTME代码用作原型。原始点的问题是,它限制了逐字标准的字符串(请参阅原始解决方案)。但是,在此示例中,水果名应该受特征标准的限制。让我解释一下我的dotme版本。

export function dotme(texts) {
  texts.each(function () {
    const text = d3.select(this);
    // get an array of characters
    const chars = text.text().split('');

    // make a temporary minimal text contains one character (space) with ...
    let ellipsis = text.text(' ').append('tspan').text('...');
    // calculate temporary minimal text width
    const minLimitedTextWidth = ellipsis.node().getComputedTextLength();
    // make "ellipsis" text object
    ellipsis = text.text('').append('tspan').text('...');

    // calculate the total text width: text + ellipsis
    // one important note here: text.attr('width') has taken from the
    // following code fragment of "":
    /*
       svg
         .selectAll('text')
         .data(normalizedData)
         // ...
         .attr('width', (d) => d.width - 1)
    */
    // that's why we must define width attribute for the text if we want to get
    // behavior of the functionality
    const width =
      parseFloat(text.attr('width')) - ellipsis.node().getComputedTextLength();
    // total number of characters
    const numChars = chars.length;
    // make unlimited version of the string
    const tspan = text.insert('tspan', ':first-child').text(chars.join(''));

    // the following case covers the situation
    // when we shouldn't display the string at all event with ellipsis
    if (width <= minLimitedTextWidth) {
      tspan.text('');
      ellipsis.remove();
      return;
    }

    // make the limited string
    while (tspan.node().getComputedTextLength() > width && chars.length) {
      chars.pop();
      tspan.text(chars.join(''));
    }

    // if all characters are displayed we don't need to display ellipsis
    if (chars.length === numChars) {
      ellipsis.remove();
    }
  });
}

我希望,就是dotme;)

用法上述功能非常简单。我们只需要致电以下内容:

svg.selectAll('text').call(dotme);

尽管重复了这一点,但由于其重要性,我仍需要再次强调它。我们必须定义文本的宽度属性。

    svg
      .selectAll('text')
      .data(normalizedData)
      .join('text')
       // ...
      .attr('width', (d) => d.width - 1)
      // ...

否则dotme提供了错误的行为。请参阅以下代码:

    const width =
      parseFloat(text.attr('width')) - ellipsis.node().getComputedTextLength();

现在是时候运行该应用了。但是以前,我想强调有关D3用法的一个关键点。让我们看以下代码行:

svg.selectAll('*').remove();

上面的代码清除了SVG上的所有图形内容。我们之所以这样做,是因为我们需要重新绘制组件,这意味着应该拒绝以前的SVG对象。您可以删除此行,重新运行应用程序并更改窗口大小。如果您想感觉到D3的工作原理,我建议您尝试一下。

这是最终解决方案的视频!

Watch the video

感谢您的关注和愉快的编码!

需要帮忙?

Valor Software成立于2013年,是一家软件开发和咨询公司,专门帮助企业现代化其网络平台和最佳杠杆技术。

通过使用Valor软件,企业可以利用最新的技术和技术来构建现代的Web应用程序,这些技术更适合不断变化的需求和需求,同时还可以通过我们的团队和社区合作伙伴通过无与伦比的OSS访问来确保最佳实践。<<<<<<<<<<<<<<<<<<<<< /p>

如果您有任何疑问,请立即联系sales@valor-software.com