d3.jsnvd3.jsng2-nvd3

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


I'm using the lineChart of NVD3 to give an evolution overview of patients' response to a treatment. Below is an example of what I'm trying to achieve; adding icons (using here font awesome) on certain entries of a series (see the smiley) :

enter image description here

Before thinking of a hack, I'm hoping for a clean solution :-) Couldn't find one yet. A hack like using a jQuery to locate values with certain meta and append icons to the dom sounds like an... ugly hack.


Solution

  • Unless there is a clean & official answer, I suppose the following can be considered a solution.

    Result obtained after this manipulation

    enter image description here

    Makes me happy. Hope it helps. To avoid the overhead of my implementation-specific needs, let's just display an "md-user" icon above each node of the graph's first series to obtain the following result:

    enter image description here

    Remarks:

    Say there's a component holding an NVD3 graph element, such as:

    my-component.html

    my-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: hooking the "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-rendering logic

    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);
        }
      }