NVD3:是否可以在图表上显示图标?

NVD3: Is it possible to display icons on the graph?

我正在使用 NVD3 的折线图来概述患者对治疗的反应。 以下是我要实现的目标的示例;在系列的某些条目上添加图标(在这里使用很棒的字体)(见笑脸):

在考虑 hack 之前,我希望有一个干净的解决方案 :-) 还没有找到。像使用 jQuery 来定位具有某些元的值并将图标附加到 dom 的 hack 听起来像一个...丑陋的 hack。

除非有一个干净的官方答案,否则我想可以考虑以下解决方案。

操作后得到的结果

让我开心。希望能帮助到你。为了避免我的特定实现需求的开销,我们只在图表的第一个系列的每个节点上方显示一个 "md-user" 图标以获得以下结果:

备注:

  • 它使用 Anular5 的 renderer(2) 组件来操作 DOM。
  • 项目本身使用 ionic3(一个 angular 包装器),但这应该不会有太大差异(除了 scss 文件中的 my-component 包装器)

假设有一个组件持有 NVD3 图形元素,例如:

我的-component.html

我的-component.scss

my-component {
  nvd3{
    position: relative;     // do NOT forget this one

    .illustrations-wrapper{
      position: absolute;
      left: 0px; // otherwise breaks on mobile safari

      .point-illustration{
        position: absolute;
        margin-bottom: 22px;

        > .vertical-line{
          position: absolute;
          top: 28px;
          border-right: 1px solid #666;
          height: 14px;
        }

        > i {
          position: absolute;
          pointer-events: none;   // without this your mouse hovering the graph wouldnt trigger highlights / popup

          &.fa-2x{
            left: -10px;         // this gets the line centered in case of icons using the font-awesome fa-2x size modifier
          }
        }
      }
    }
  }
}

my-component.ts: 挂钩 "after graph rendered event"

private initGraphOptions(): void {
    this.graphOptions = {
      "chart": {
        "type": "lineChart",
        "callback": (chart) => {

          if(!chart) return;

          // Listen to click events
          chart.lines.dispatch.on('elementClick', (e) => this.entryClicked(e[0].point.x) );

          // Add some overlaying icons when rendering completes
          chart.dispatch.on('renderEnd', () => this.onRenderComplete() );

          // Customize tooltip
          chart.interactiveLayer.tooltip.contentGenerator(this.tooltipContentGenerator);
          return chart;
        },

        "height": 450,
        "margin": { ... },
        "useInteractiveGuideline": true,
        "dispatch": {},
        "xAxis": { ... }
        "yDomain": [-100, 100],
        "yAxis": { ... }
      }
    };
  }

my-component.ts: post-渲染逻辑

private onRenderComplete(): void {
    this.ensurePresenceOfIllustrationsLayer() && this.renderGraphIcons();
  }

  private ensurePresenceOfIllustrationsLayer() : boolean {

    // We wish to create a child element to our NVD3 element.
    // In this case, I can identify my element thanks to a class named "score-graph".
    // This is the current component's DOM element
    const hostElement = this.elementRef.nativeElement;
    const nvd3Element = hostElement.getElementsByClassName('score-graph')[0];
    if(!nvd3Element) return false; // in case something went wrong

    // Add a wrapper div to that nvd3 element. Ensure it has the "illustrations-wrapper" class to apply propre positionning (see scss)
    const wrapper = this.renderer.createElement('div');
    this.renderer.addClass(wrapper, 'illustrations-wrapper');
    this.renderer.appendChild(nvd3Element, wrapper);  // woa angular is just awesome

    // Our nvd3 element now has two children: <svg> and <div.illustrations-wrapper>
    return true;
  }

  private renderGraphIcons(): void {

    if(!(this.data && this.data[0]) ) return;

    // This is the current component's DOM element
    const hostElement = this.elementRef.nativeElement;

    // The illustration wrapper will hold en entry per point to illustrate
    const illustrationWrapper = hostElement.getElementsByClassName('illustrations-wrapper') && hostElement.getElementsByClassName('illustrations-wrapper')[0];
    if(!illustrationWrapper) return;

    // We are looking for a class named after the series we wish to process illustrate. In this case, we need "nv-series-0"
    // However, there's two elements bearing that class:
    // - under "nv-groups", where lines are drawn
    // - under "nv-scatterWrap", where nodes (dots) are rendered.
    // We need the second one. Let's even take its first most relevant child with a unique class under the hostElement: "nv-scatter"
    const scatter = hostElement.getElementsByClassName('nv-scatter') && hostElement.getElementsByClassName('nv-scatter')[0];
    if(!scatter && !scatter.getElementsByClassName) return; // in case something went wrong

    // Now there will only be one possible element when looking for class "nv-series-0"
    // NB. you can also use the "classed" property of a series when defining your graph data to assign a custom class.

    const targetSeries = scatter.getElementsByClassName('nv-series-0') && scatter.getElementsByClassName('nv-series-0')[0];
    if(!targetSeries && !targetSeries.getElementsByClassName) return; // in case something went wrong

    // Now it gets nice. We get the array of points (as DOM "path" elements)
    const points: any[] = targetSeries.getElementsByClassName('nv-point');

    // You think this is a dirty hack so far? Well, keep reading. It gets worse.
    const seriesData = this.data[0];
    if(points.length !== seriesData.values.length) return; // not likely, but you never know.

    for(let i = 0; i < points.length; i++){
      const point = points[i];
      const pointData = seriesData.values[i];

      let translationX = 0;
      let translationY = 0;


      try{

         // How far is this point from the left border?
          let translation = point.getAttribute('transform'); // this wll get you a string like "translate(0,180)" and we're looking for both values
          translation = translation.replace('translate(', '').replace(')', '').split(',');  // Ok I might rot for this ugly regex-lazy approach

        // What's the actual possibly scaled-down height of the graph area? Answer: Get the Y translation applied to the x-axis
        const nvx = hostElement.getElementsByClassName('nv-x')[0];
        const xAxisTranslate = nvx.getAttribute('transform').replace('translate(', '').replace(')', '').split(',');  // Same comment

        translationX = Number.parseInt(translation[0]);
        translationY = Number.parseInt(xAxisTranslate[1]) / 2;
      }
      catch(err){
      }


      const translationOffsetX = 0;
      const translationOffsetY = 52; // "trial and error" will tell what's best here

      // Let's add an illustration entry here
      const illustration = this.renderer.createElement('div');
      this.renderer.addClass(illustration, 'point-illustration')
      this.renderer.setStyle(illustration, 'left', (translationX + translationOffsetX) + 'px');
      this.renderer.setStyle(illustration, 'bottom', (translationY + translationOffsetY) + 'px');
      this.renderer.appendChild(illustrationWrapper, illustration);

      // Optional: add a a visual connexion between the actual chart line and the icon (define this first so that the icon overlaps)
      const verticalLine = this.renderer.createElement('div');
      this.renderer.addClass(verticalLine, 'vertical-line');
      this.renderer.appendChild(illustration, verticalLine);

      // Add the icon
      const icon = this.renderer.createElement('i');
      this.renderer.addClass(icon, 'fa');
      this.renderer.addClass(icon, 'fa-user-md');
      this.renderer.addClass(icon, 'fa-2x');
      this.renderer.appendChild(illustration, icon);
    }
  }