用反作用和D3建立一个动画纯奇异值动态高度手风琴


这是我为客户建立的概念证明。他们正在构建一个事件流数据可视化,并不满意他们的组件变得多么挑剔和难以使用。

所以他们向我求助。

目标

目标是拥有一个事件流可视化,其中图标代表不同类型的事件,用一条不间断的线连接,并在单击时显示附加信息。附加信息可以包含子流、进一步的数据可视化或仅仅是文本。

它的灵感来自于GitHub如何可视化公关过程,但它的目的是为了更丰富的图形。因此,它必须在支持向量机中。

Image title

GitHub使用带有描述的连接事件图标显示了公关过程。

要解决的问题/要抓住的难题

当你意识到奇异值分解是可怕的用于布局。你想要SVG是因为它非常适合数据——有趣的形状,矢量图形,元素定位的完全控制。太好了。

而且可以完全控制元件定位...

你知道,权力越大,工作量越大。在超文本标记语言为你执行基本布局的地方,超文本标记语言没有。想要文本流入新行吗?自己动手。想让元素把其他元素推开吗?自己动手。想要发生什么吗?自己动手。

当你事先掌握了所有的信息,这很好。

但是当你不能知道某些元素的大小时,那就太可怕了。比如右边那些动态块的高度。

我的客户尝试了几种方法,最终确定了一个有点像这样的组件结构:

Image title

几乎足够好的结构

每行都是一个<div>包含一个<svg>在左边还有一个<div>在右边。SVG渲染我们的图标和垂直线。这div包含可能扩展的描述。

当内心div变大时,它会调整容器的大小div。这会将下面的行推得更低。

一切都很好。

但是它很挑剔,很难对齐,你甚至不想知道当有人调整他们的浏览器时会发生什么,元素开始分成新的行。

因此,目标是建立一个解决方案:

  • 并不挑剔。
  • 有一个简单的应用编程接口。
  • 适用于任意行高。
  • 可以处理初始渲染后的行大小调整。
  • 允许用户将任何内容呈现到这个结构中。

解决方案

渲染SVG中的所有内容,使用<foreignObject>来支持右边的自动文本布局,并滥用React ref回调来处理动态高度。

简而言之:

  • <AccordionFlow>呈现行。
  • <AccordionFlow>保存已知或默认行高的数组,用于垂直定位。
  • <Row>获得2个渲染道具,iconcontent
  • icon渲染左侧。
  • content呈现中的右侧<foreignObject>
  • 上的引用回调content触发高度更新回调。
  • 高度更新回调更新中的高度列表<AccordionFlow>
  • 这将触发重新渲染,并且每个<Row>声明性地转换到新的位置。
  • content更新自己,它调用一个回调<Row>它的高度变了。
  • 同样的回流发生在以前。

让我们看看代码。

index.js

你可以把这看作是消费者的一面。无论是谁需要呈现我们的AccordionFlow

它首先创建一个数组数组来表示我们的数据。每个都有一个<Icon>和一个<Content>

// index.js prep data

const icons = [&lt;Circle /&gt;, &lt;Rectangle /&gt;, &lt;Triangle /&gt;],
  flowData = d3range(10).map(i =&gt; [
    icons[i % 3],
    contentUpdated =&gt; (
      &lt;DynamicContent title={`Row ${i}`} contentUpdated={contentUpdated}&gt;
        {d3range(10)
          .slice(0, i)
          .map(() =&gt; faker.lorem.paragraph())}
      &lt;/DynamicContent&gt;
    )
  ]);

图标是一个普通组件。我们将它呈现为一个没有装饰的复合组件。<DynamicContent>被包装在一个函数中,因为它将被用作渲染道具。它获得回调函数,可以调用该函数在初始渲染后动态更新其高度。

我们非常需要这个AccordionFlow可以知道何时将其他行推开。

呈现我们的数据意味着简单:

// index.js render method
    &lt;svg width="600" height="2240"&gt;
      &lt;AccordionFlow data={flowData} /&gt;
    &lt;/svg&gt;

Svg非常高,为扩展留出空间。我们能够包装AccordionFlow并使svg高度动态,但这是一个概念的证明。

一致流是我们渲染每一行的地方,左边的垂直线,为每一行保留一个已知高度的列表,并处理垂直定位。

使用惯用的但不是最易读的代码,它有30行。

// AccordionFlow

class AccordionFlow extends React.Component {
  defaultHeight = 50;
  state = {
    heights: this.props.data.map(_ =&gt; this.defaultHeight)
  };
  render() {
    const { data } = this.props,
      { heights } = this.state;

    return (
      &lt;g transform="translate(0, 20)"&gt;
        &lt;line
          x1={15}
          x2={15}
          y1={10}
          y2={heights.reduce((sum, h) =&gt; sum + h, 0)}
          stroke="lightgrey"
          strokeWidth="2.5"
        /&gt;
        {data.map(([icon, content], i) =&gt; (
          &lt;Row
            icon={icon}
            content={content}
            y={heights.slice(0, i).reduce((sum, h) =&gt; sum + h, 0)}
            width={450}
            key={i}
            reportHeight={height =&gt; {
              let tmp = [...heights];
              tmp[i] =
                height !== undefined &amp;&amp; height &gt; this.defaultHeight
                  ? height
                  : this.defaultHeight;
              this.setState({ heights: tmp });
            }}
          /&gt;
        ))}
      &lt;/g&gt;
    );
  }
}

我们从违约开始heights50像素,并呈现分组元素<g>帮助定位。在内部,我们渲染一个垂直<line>连接所有图标,然后进入行循环。

每个<Row>获取:

  • icon渲染道具。
  • Acontent渲染道具。
  • 竖直位置y计算为所有的总和heights目前为止。
  • Awidth帮助它计算出它的高度。
  • Akey这样反应就不会抱怨了。
  • AreportHeight回调,它更新了我们heights阵列。

我们可能应该将回调移动到一个类方法中,但是我们需要封装索引。我们能做的最好的事情是height => this.reportHeight(height, i)

<Row>组件更残忍。它呈现图标和内容,处理高度回调,并使用我的declarative D3 transitions with React 16.3+动画显示其垂直位置的方法。

总共64行代码。

class Row extends React.Component {
  state = {
    open: false,
    y: this.props.y
  };

  toggleOpen = () =&gt; this.setState({ open: !this.state.open });

    // needed for animation
  rowRef = React.createRef();

    // magic dynamic height detection
  contentRefCallback = element =&gt; {
    if (element) {
      this.contentRef = element;
      this.props.reportHeight(element.getBoundingClientRect().height);
    } else {
      this.props.reportHeight();
    }
  };

  contentUpdated = () =&gt; {
    this.props.reportHeight(this.contentRef.getBoundingClientRect().height);
  };

  componentDidUpdate() {
    // handle animation
  }

  render() {
      // render stuff
  }
}

我们的<Row>组件使用state追踪它是否open和它的垂直位置y

我们使用toggleOpen翻转open开关。现在,这仅仅意味着content是否被渲染。

有趣的事情发生在contentRefCallback。我们用这个作为React ref callback,这是一个当一个新元素被呈现到DOM中时进行反应的方法。

我们利用这个机会将参考作为一个组件属性保存,以备将来参考getBoundingClientRect,并调用reportHeight回拨告知<AccordionRow>关于我们的新高度。

类似的事情发生在contentUpdated。这是我们传递给content渲染道具以便它能告诉我们什么时候有变化。然后,我们重新检查我们的新高度,并将其报告给上级。

提出

render方法将所有这些放在一起。

render() {
    const { icon, content, width } = this.props,
      { y } = this.state;

    return (
      &lt;g transform={`translate(5, ${y})`} ref={this.rowRef}&gt;
        &lt;g onClick={this.toggleOpen} style={{ cursor: "pointer" }}&gt;
          {icon}
        &lt;/g&gt;
        {this.state.open ? (
          &lt;foreignObject
            x={20}
            y={-20}
            width={width}
            style={{ border: "1px solid red" }}
          &gt;
            &lt;div ref={this.contentRefCallback}&gt;
              {typeof content === "function"
                ? content(this.contentUpdated)
                : content}
            &lt;/div&gt;
          &lt;/foreignObject&gt;
        ) : null}
      &lt;/g&gt;
    );
  }

我们从分组元素开始<g>处理定位并设置rowRef

在内部,我们首先呈现一个带有点击回调的分组元素icon。这让我们打开和关闭一行。

接下来是一个foreignObject包含我们的content。外来对象是SVG元素,可以让你渲染任何东西。超文本标记语言,更多的支持向量机,....HTML。主要是超文本标记语言,这就是魔法。

foreignObject有一个x,y位置来弥补一些滑稽,和width这有助于浏览器的布局引擎决定做什么。

然后它包含一个div所以我们可以把我们的contentRefCallback因为穿上它foreignObject总是报告高度为0。我不知道为什么。

然后我们呈现我们的content作为传递给contentUpdated回调或简单组件。

组件更新

componentDidUpdate以声明方式处理每行垂直位置的垂直转换。你应该看看我的Declarative D3 transitions with React 16.3详情见文章。

componentDidUpdate() {
    const { y } = this.props;
    d3
      .select(this.rowRef.current)
      .transition()
      .duration(500)
      .ease(d3.easeCubicInOut)
      .attr("transform", `translate(5, ${y})`)
      .on("end", () =&gt; {
        this.setState({
          y
        });
      });
  }

这个想法是我们把新的y正确,直接使用D3转换我们的DOM节点,然后更新我们的state保持反应与现实同步。

说实话<DynamicContent>不是这段代码的有趣之处。为了完整起见,我将它包含在这里。

它是一个组件,呈现一个标题和一些段落,等待1.2秒,然后添加另一个段落。还有一个旋转器可以让事情变得更有趣。

lass DynamicContent extends React.Component {
  state = {
    paragraphs: this.props.children,
    spinner: true
  };
  componentDidMount() {
    setTimeout(() =&gt; {
      this.setState({
        paragraphs: [...this.state.paragraphs, faker.lorem.paragraph()],
        spinner: false
      });
      this.props.contentUpdated();
    }, 1200);
  }

  render() {
    const { title } = this.props,
      { paragraphs, spinner } = this.state;

    return (
      &lt;React.Fragment&gt;
        &lt;h3&gt;{title}&lt;/h3&gt;
        {paragraphs.map(c =&gt; &lt;p&gt;{c}&lt;/p&gt;)}
        &lt;p&gt;
          {spinner &amp;&amp; (
            &lt;Spinner
              name="cube-grid"
              color="green"
              fadeIn="none"
              style={{ margin: "0 auto" }}
            /&gt;
          )}
        &lt;/p&gt;
      &lt;/React.Fragment&gt;
    );
  }
}

看,州持有段落和旋转标志。componentDidMount超时为1.2秒,然后添加另一个段落并调用contentUpdated回拨。

呈现方法返回一个React.Fragment包含一个<h3>title、一堆段落和一个可选的Spinnerreact-spinkit

没什么特别的,但是动态的东西可能会对我们的AccordionFlow没有那个回电。

快乐黑客!