htmlangularsvgpie-chart

Why does it look like the slices of my diy svg-path pie chart do not converge in the center?


I tried to create a dynamic pie-chart with angular and the svg path element.

I succeeded in principle, but it seems that the individual slices do not properly converge in the center. The effect gets worse when one chooses a bigger stroke-width but even with no stroke-width it is still noticeable.

Why is that the case and how can I solve it?

Run it on stackblitz

import {Component} from '@angular/core';
import {bootstrapApplication} from '@angular/platform-browser';

@Component({
  selector: 'app-root',
  template: `
  <div style="background-color: #dddddd; width: 1000px; height: 1000px;">
    <svg viewBox="-5 -5 105 105">
      <g transform="translate(50, 50)">        
      @for( p of paths; track $index ){
        <path stroke-width="1" stroke="black" [attr.fill]="colors[$index]" [attr.d]="p"></path>
      }
      </g>
    </svg>
  </div>
  `,
})
export class Playground {
  paths : string[] = [];
  colors : string[] = [];
  

  constructor(){
   const d = 4; // decimal places
   const angles = [0, 1, 2, 2.4, 4, 5, 2*Math.PI];  
   const n = angles.length;
   const r = 25;    
   for( let i = 0; i < n-1; ++i ){
     this.colors.push(this.getRandomColor())
     const a0 = angles[i];
     const a1 = angles[i+1];
     const dA = (a1 - a0)/Math.PI * 180;

     const x0 = r * Math.cos(a0);
     const y0 = -r * Math.sin(a0);
     const x1 = r * Math.cos(a1);
     const y1 = -r * Math.sin(a1);

    const largeAngle = dA < 180 ? 0 : 1;
     
     const path = `
     M 0 0 
     L ${x0.toFixed(d)} ${y0.toFixed(d)} 
     A ${r.toFixed(d)} ${r.toFixed(d)} -${dA.toFixed(d)} ${largeAngle} 0 ${x1.toFixed(4)} ${y1.toFixed(4)}
     L 0 0 z`;

    console.log(path);
     
     this.paths.push(path);
   } 
  }  

private getRandomColor() {
  var letters = '0123456789ABCDEF';
  var color = '#';
  for (var i = 0; i < 6; i++) {
    color += letters[Math.floor(Math.random() * 16)];
  }
  return color;
}

}

bootstrapApplication(Playground);

enter image description here enter image description here


Solution

  • TL;DR: it's the renderer not your coordinates

    Your calculations don't introduce this issue.

    This known issue stems from anti-aliasing when SVG coordinates and the "intrinsic" coordinate space don't perfectly match the ultimate device pixel-grid.

    So depending on the scaling – gaps may be visible in an SVG, despite the fact start and end coordinates of its edges are coinciding geometrically/numerically.

    We can even illustrate this phenomenon with a far more simple example rendering adjacent rectangles (at least in Chromium/Blink browsers – Firefox has a smarter algorithm for adjacent horizontal or vertical edges):

    .resize {
      resize: both;
      overflow: auto;
      outline: 2px dashed #ccc;
    
    }
    <h3>Adjacent rectangles – no gap existent</h3>
    <div class="resize">
      <svg viewBox="0 0 10 10">
        <rect y="0" width="10" height="4.1" fill="#333" />
        <rect y="4.1" width="10" height="5" fill="#333" />
      </svg>
    </div>
    
    <h3>Adjacent rectangles – might be floating point coordinates?</h3>
    <p>... No not really: All coordinates use integers</p>
    <div class="resize">
      <svg viewBox="0 0 100 100">
        <rect y="0" width="100" height="41" fill="#333" />
        <rect y="41" width="100" height="50" fill="#333" />
      </svg>
    </div>
    
    
    <h3>Take intrinsic dimensions and integers</h3>
    <svg viewBox="0 0 100 100" width="100">
        <rect y="0" width="100" height="41" fill="#333" />
        <rect y="41" width="100" height="50" fill="#333" />
      </svg>
    
    <h3>Take intrinsic dimensions and floats</h3>
    <svg viewBox="0 0 100 100" width="100">
        <rect y="0" width="100" height="41.1" fill="#333" />
        <rect y="41.1" width="100" height="50" fill="#333" />
      </svg>
    
    
    <h3>shape-rendering="crispEdges"</h3>
    <svg viewBox="0 0 100 100">
      <rect y="0" width="100" height="40.1" fill="#333" shape-rendering="crispEdges" />
      <rect y="40.1" width="100" height="50" fill="#333" shape-rendering="crispEdges" />
    </svg>
    
    <h3>shape-rendering="geometricPrecision"</h3>
    <p>... Slightly disappointing on Chromium</p>
    <svg viewBox="0 0 100 100">
      <rect y="0" width="100" height="40.1" fill="#333" shape-rendering="geometricPrecision" />
      <rect y="40.1" width="100" height="50" fill="#333" shape-rendering="geometricPrecision" />
    </svg>

    The results will differ slightly across different browsers. However, this is not necessarily an issue related to floating-point coordinates, but rather it is affected by the current scaling passed to the renderer, which may introduce semi-transparent adjacent edge pixels. Ultimately, these produce the "halos" or hairlines.

    Diagonals make things more noticeable.

    Workarounds

    1. cheat! we can close these non existing gaps by adding strokes of the same fill color around the pie-chart segments
      1.2 Duplicate the pie segments as a background with strokes to fill the gaps
    2. Tweak rendering behavior via shape-rendering
    3. Add significant visible gaps as a part of your design concept: Also, be aware that some users may have visual impairments, such as colour vision deficiencies or other forms of reduced vision, which can make distinguishing between pie-chart segments difficult.
      In terms of both accessibility and ease of use, this is probably the best workaround.

    svg {
      display: block;
      outline: 1px solid #ccc;
      overflow: visible;
    }
    
    .stroke path {
      stroke-width: 0.1px;
    }
    
    .no-stroke path {
      stroke: none;
      fill: #000;
    }
    
    .resize {
      resize: both;
      overflow: auto;
      outline: 2px dashed #ccc;
    
    }
    <h3>Gaps due to rendering</h3>
    <p>Notice small mageta lines blinking though the segments</p>
    <div class="resize">
      <svg class="no-stroke" viewBox="-25 -25 50 50">
        <!-- magenta background circle -->
        <circle cx="0" cy="0" fill="magenta" r="25" />
        <path stroke="black" fill="#A95992" d="M 0 0  L 25 0  A 25 25 0  0 0 13.5076 -21.0368 z" />
        <path stroke="black" fill="#2B37F6" d="M 0 0  L 13.5076 -21.0368  A 25 25 0  0 0 -10.4037 -22.7324 z" />
        <path stroke="black" fill="#94F090" d="M 0 0  L -10.4037 -22.7324  A 25 25 0 0 0 -18.4348 -16.8866 z" />
        <path stroke="black" fill="#C7016A" d="M 0 0  L -18.4348 -16.8866  A 25 25 0  0 0 -16.3411 18.9201 z" />
        <path stroke="black" fill="#68015C" d="M 0 0  L -16.3411 18.9201  A 25 25 0  0 0 7.0916 23.9731 z" />
        <path stroke="black" fill="#AE2EFC" d="M 0 0  L 7.0916 23.9731  A 25 25 0 0 0 25 0 z" />
      </svg>
    </div>
    
    
    <h2>Workarounds</h2>
    <h3>Add strokes</h3>
    <p>Not ideal - we see non-convergent edges in the center</p>
    <div class="resize">
      <svg class="stroke" viewBox="-25 -25 50 50">
        <path stroke="#A95992" fill="#A95992" d="M 0 0  L 25 0  A 25 25 0  0 0 13.5076 -21.0368 z" />
        <path stroke="#2B37F6" fill="#2B37F6" d="M 0 0  L 13.5076 -21.0368  A 25 25 0  0 0 -10.4037 -22.7324 z" />
        <path stroke="#94F090" fill="#94F090" d="M 0 0  L -10.4037 -22.7324  A 25 25 0 0 0 -18.4348 -16.8866 z" />
        <path stroke="#C7016A" fill="#C7016A" d="M 0 0  L -18.4348 -16.8866  A 25 25 0  0 0 -16.3411 18.9201 z" />
        <path stroke="#68015C" fill="#68015C" d="M 0 0  L -16.3411 18.9201  A 25 25 0  0 0 7.0916 23.9731 z" />
        <path stroke="#AE2EFC" fill="#AE2EFC" d="M 0 0  L 7.0916 23.9731  A 25 25 0 0 0 25 0 z" />
      </svg>
    </div>
    
    <h3>Add strokes in background</h3>
    <p>Fill potential gaps in a more subtle fashion</p>
    <div class="resize">
      <svg viewBox="-25 -25 50 50">
    
        <g id="bg" class="stroke">
          <path stroke="#A95992" fill="#A95992" d="M 0 0  L 25 0  A 25 25 0  0 0 13.5076 -21.0368 z" />
          <path stroke="#2B37F6" fill="#2B37F6" d="M 0 0  L 13.5076 -21.0368  A 25 25 0  0 0 -10.4037 -22.7324 z" />
          <path stroke="#94F090" fill="#94F090" d="M 0 0  L -10.4037 -22.7324  A 25 25 0 0 0 -18.4348 -16.8866 z" />
          <path stroke="#C7016A" fill="#C7016A" d="M 0 0  L -18.4348 -16.8866  A 25 25 0  0 0 -16.3411 18.9201 z" />
          <path stroke="#68015C" fill="#68015C" d="M 0 0  L -16.3411 18.9201  A 25 25 0  0 0 7.0916 23.9731 z" />
          <path stroke="#AE2EFC" fill="#AE2EFC" d="M 0 0  L 7.0916 23.9731  A 25 25 0 0 0 25 0 z" />
        </g>
    
        <!-- foreground -->
        <path fill="#A95992" d="M 0 0  L 25 0  A 25 25 0  0 0 13.5076 -21.0368 z" />
        <path fill="#2B37F6" d="M 0 0  L 13.5076 -21.0368  A 25 25 0  0 0 -10.4037 -22.7324 z" />
        <path fill="#94F090" d="M 0 0  L -10.4037 -22.7324  A 25 25 0 0 0 -18.4348 -16.8866 z" />
        <path fill="#C7016A" d="M 0 0  L -18.4348 -16.8866  A 25 25 0  0 0 -16.3411 18.9201 z" />
        <path fill="#68015C" d="M 0 0  L -16.3411 18.9201  A 25 25 0  0 0 7.0916 23.9731 z" />
        <path fill="#AE2EFC" d="M 0 0  L 7.0916 23.9731  A 25 25 0 0 0 25 0 z" />
    
      </svg>
    
    </div>
    
    
    <h3>Add strokes as a design concept</h3>
    <div class="resize">
      <svg viewBox="-25 -25 50 50" stroke="white">
    
        <!-- foreground -->
        <path fill="#A95992" d="M 0 0  L 25 0  A 25 25 0  0 0 13.5076 -21.0368 z" />
        <path fill="#2B37F6" d="M 0 0  L 13.5076 -21.0368  A 25 25 0  0 0 -10.4037 -22.7324 z" />
        <path fill="#94F090" d="M 0 0  L -10.4037 -22.7324  A 25 25 0 0 0 -18.4348 -16.8866 z" />
        <path fill="#C7016A" d="M 0 0  L -18.4348 -16.8866  A 25 25 0  0 0 -16.3411 18.9201 z" />
        <path fill="#68015C" d="M 0 0  L -16.3411 18.9201  A 25 25 0  0 0 7.0916 23.9731 z" />
        <path fill="#AE2EFC" d="M 0 0  L 7.0916 23.9731  A 25 25 0 0 0 25 0 z" />
    
      </svg>
    
    </div>
    
    
    
    <h3>Add gaps in the arc calculations </h3>
    <p>The gaps are programmatically subtracted from the arc starting and end points</p>
    
    <svg viewBox="0 0 100 100">
    <path d="M 51 0.01 A 50 50 0 0 1 84.641 13.945 L 63.418 35.169 A 20 20 0 0 0 51 30.025 z" fill="#bacc33" class="segment segment-12_500 segment-1 " data-percent="12.500"></path>
    <path d="M 86.055 15.359 A 50 50 0 0 1 99.99 49 L 69.975 49 A 20 20 0 0 0 64.831 36.582 z" fill="#4fcc33" class="segment segment-12_500 segment-2 " data-percent="12.500"></path>
    <path d="M 99.99 51 A 50 50 0 1 1 49 0.01 L 49 30.025 A 20 20 0 1 0 69.975 51 z" fill="#cc3333" class="segment segment-75_000 segment-3 " data-percent="75.000"></path></svg>

    All in all it's not you script but the rendering/rasterization.
    However, you can simplify your pathData output

    const path = `
      M 0 0 
      L ${+x0.toFixed(d)} ${+y0.toFixed(d)} 
      A ${+r.toFixed(d)} ${+r.toFixed(d)} 0 ${largeAngle} 0 
      ${+x1.toFixed(4)} ${+y1.toFixed(4)}
        z`;
    

    Since we're dealing with circular arcs we don't need a xAxisRotation value – if rx===ry this parameter is ignored. Also, better convert rounded values to numbers to avoid unnecessary decimals. In your case: We also don't need the explicit lineto closing the path to the starting point – the Z command already does this.

    Related posts