Blog der Heimetli Software AG

Polare Darstellung mit D3 animieren

Trotz grossem Aufwand überzeugt das polare Plotly-Diagramm nicht wirklich.

Also habe ich mich mit D3 nochmals an die Arbeit gemacht, und das Resultat sieht eindeutig besser aus. Damit die beiden vergleichbar sind, ist der Stil ziemlich ähnlich.

Die Ladezeit der beiden Seiten ist nicht zu vergleichen, die D3-Version ist sehr viel schneller als Plotly. Die Animation läuft ebenfalls deutlich schneller, und die Geschwindigkeit liesse sich noch steigern.

Das Programm dazu

Der Code ist um einiges länger als bei der Python-Version, und auch nicht so einfach zu schreiben:

const months = [
      {  "name": "Jan", "angle": 0           },
      {  "name": "Feb", "angle": 30.57534247 },
      {  "name": "Mär", "angle": 58.19178082 },
      {  "name": "Apr", "angle": 88.76712329 },
      {  "name": "Mai", "angle": 118.3561644 },
      {  "name": "Jun", "angle": 148.9315068 },
      {  "name": "Jul", "angle": 178.5205479 },
      {  "name": "Aug", "angle": 209.0958904 },
      {  "name": "Sep", "angle": 239.6712329 },
      {  "name": "Okt", "angle": 269.2602740 },
      {  "name": "Nov", "angle": 299.8356164 },
      {  "name": "Dez", "angle": 329.4246575 } ] ;

d3.json( "polar.json" ).then( data => {
     const svg    = d3.select( "#polar" ) ;
     const rscale = d3.scaleLinear().domain( [0,75] ).range( [0,300] ) ;
     const line   = d3.lineRadial().radius( d => rscale(d.value) ).angle( (_,i) => i / 365 * 2 * Math.PI ) ;
     let   end    = 2 ;
     let   auto   = true ;

     const group  = svg.append( "g" )
                       .attr( "transform", "translate(330,330)" ) ;

     const gr     = group.selectAll( "g" )
                       .data( [15,30,45,60,75] )
                       .enter()
                          .append( "g" ) ;

     const ga     = group.append( "g" )
                       .selectAll( "g" )
                       .data( months )
                       .enter()
                          .append( "g" )
                          .attr( "transform", d => `rotate(${d.angle-90})` ) ;

    gr.append( "circle" )
       .attr( "r", d => rscale(d) )
       .attr( "fill", "none" )
       .attr( "stroke", "rgb(64,64,64)" ) ;

     gr.append( "text" )
        .attr( "fill", "white" )
        .attr( "transform", "rotate(15)" )
        .attr( "y", d => -rscale(d) - 5 )
        .attr( "text-anchor", "middle" )
        .text( d => d ) ;

     ga.append( "text" )
        .attr( "x", "305" )
        .attr( "fill", "white" )
        .attr( "text-anchor", "middle" )
        .attr( "transform", d => `rotate(${90},305,0)` )
        .text( d => d.name ) ;

     ga.append( "line" )
        .attr( "x2", "300" ) 
        .attr( "fill", "none" )
        .attr( "stroke", "rgb(64,64,64)" ) ;

     group.append( "path" )
        .attr( "d", line(data.slice(0,2)) )
        .attr( "fill", "none" ) ;

     const tooltip = group.append( "g" )
        .style( "display", "none" ) ;

     tooltip.append( "rect" )
        .attr( "width", 156 )
        .attr( "height", 50 )
        .attr( "rx", 4 )
        .attr( "ry", 4 ) ;

     tooltip.append( "text" )
        .attr( "id", "upper-date" )
        .attr( "fill", "black" )
        .attr( "x", 10 )
        .attr( "y", 30 ) ;

     tooltip.append( "text" )
        .attr( "id", "upper-value" )
        .attr( "fill", "black" )
        .attr( "x", 112 )
        .attr( "y", 30 ) ;

     tooltip.append( "text" )
        .attr( "id", "lower-date" )
        .attr( "fill", "black" )
        .attr( "x", 10 )
        .attr( "y", 39 ) ;

     tooltip.append( "text" )
        .attr( "id", "lower-value" )
        .attr( "fill", "black" )
        .attr( "x", 112 )
        .attr( "y", 39 ) ;

     function updateTooltip( pos )
     {
        const index = Math.round( (Math.atan2( -pos[0], pos[1] ) + Math.PI) / (2 * Math.PI) * 365 ) ;
        const value = rscale.invert( Math.sqrt(pos[0]*pos[0]+pos[1]*pos[1]) ) ;

        if( end > index )
        {
           tooltip.select( "#upper-date" ).text( data[index].day ) ;
           tooltip.select( "#upper-value" ).text( data[index].value.toFixed(1) ) ;
        }

        if( end > index + 365 )
        {
           tooltip.select( "#upper-date").attr(  "y", 21 ) ;
           tooltip.select( "#upper-value").attr( "y", 21 ) ;
           tooltip.select( "#lower-date" ).style( "display", null ).text( data[index+365].day ) ;
           tooltip.select( "#lower-value" ).style( "display", null ).text( data[index+365].value.toFixed(1) ) ;
        }
        else
        {
           tooltip.select( "#upper-date").attr( "y", 32 ) ;
           tooltip.select( "#upper-value").attr( "y", 32 ) ;
           tooltip.select( "#lower-date" ).style( "display", "none" ) ;
           tooltip.select( "#lower-value" ).style( "display", "none" ) ;
        }

        if( (end < index) || (value < 32) || (value > 72) )
        {
           tooltip.style( "display", "none" ) ;
        }
        else
        {
           tooltip.attr( "transform", `translate(${pos[0]-78},${pos[1]-53})` ) ;
           tooltip.style( "display", null ) ;
        }
     }

     group.on( "mouseover", () => tooltip.style("display",null)   ) ;
     group.on( "mouseout", ()  => tooltip.style("display","none") ) ;
     group.on( "mousemove", function() { updateTooltip(d3.mouse(this)) ; } ) ;

     function updateView( end )
     {
        document.querySelector( "#day" ).textContent = data[end].day ;
        
        group.select( "path" )
           .attr( "d", line(data.slice(0,end+1)) )
           .attr( "fill", "none" )
           .attr( "stroke", "rgb(240,249,33)" ) ;
     }

     function update()
     {
        if( auto && (end < data.length) )
        {
           document.querySelector( "#slider" ).value = end ;
           updateView( end++ ) ;
           setTimeout( update, 50 ) ;
        }
     }

     function sliderInput()
     {
        auto = false ;
        end  = +this.value ;
        updateView( end ) ;
     }

     update() ;

     document.querySelector( "#slider" ).addEventListener( "input", sliderInput ) ;
}) ;

Die Daten

JavaScript und JSON gehören zusammen, und deshalb sind die Daten als JSON abgelegt. Irgendwelche seltsamen Tricks wie bei Plotly sind nicht nötig:

[{"day":"01.01.2018","value":33.0264791275622},
{"day":"02.01.2018","value":50.23564716870469},
{"day":"03.01.2018","value":53.15636351184351},
{"day":"04.01.2018","value":53.3859972224156},
{"day":"05.01.2018","value":54.71566858468588},
.
.