cssdomsvg

Is it possible to make SVG appear consistent around sharp corners with stroke-dasharray?


I'm working on an interactive application allowing direct manipulation of shapes via SVG. I'll start with a specific example then ask a general question. The example given is how it appears rendered in Chrome.

Given many possible values of stroke-dasharray, this star's arms have inconsistent strokes. 3 edges appear blunt, 2 appear sharp. stroke-linejoin would change the appearance of the star, but it does not address the inconsistency across every arm.

<svg width="800" height="600" xmlns="http://www.w3.org/2000/svg"> 
  <polygon stroke="red"
           stroke-width="20"
           stroke-dasharray="5 5"
           points="350,75  379,161 469,161 397,215
                      423,301 350,250 277,301 303,215
                      231,161 321,161" />
</svg>

ugly star

While fiddling might each arm's stroke look consistent in this particular case using stroke-dashoffset, I doubt that one can make sharp turns in strokes look consistent in general given how stroke-dasharray works. However, there is an internal demand from my team to make them consistent so I have to look into this.

So to confirm: Is there a general solution to make strokes appear consistent around sharp corners using stroke-dasharray?


Solution

  • UPDATE: This strategy can now be used for svg <path> elements (including curved segments), not just <polygon> elements. With minor modifications it can, I believe, be used for any SVG shapes, though I only demonstrate it here for polygons and paths.

    Polygons (does not require any polyfills)

    The function below allows you to programmatically calculate the required dash and gap lengths and apply them as a stroke-dasharray to a polygon element. As a bonus, it also allows you to choose whether you want dashes at ALL corners (left image) or NO corners (right image).

    enter image description here

    For each polygon line segment, the function calculates the length of the segment and then calculates the number of dashes required to begin and end that segment with half-length gaps. It then calculates the exact dash length required and creates the necessary stroke-dasharray of dashes/gaps. For example, if there is room for 3 dashes of length d (equivalent to 6 dashes-or-gaps), the stroke-dasharray would be d/2 d d d d d d/2. This would start and end the line segment with half-dashes, as follows:

    xx----xxxx----xxxx----xx

    Do this for each edge, combining the last half-length dash of one edge with the first half-length dash of the next edge, e.g. ...

    xx----xxxx----xxxx----xx + XXX------XXXXXX------XXXXXX------XXX

    ...becomes...

    xx----xxxx----xxxx----xxXXX------XXXXXX------XXXXXX------XXX

    The function also allows you to set the noneFlag to true (default is false), converting the stroke from having dashes at all corners to having dashes at no corners. It does this by simply prepending a zero at the start of the stroke-dasharray, effectively converting all dashes to gaps and all gaps to dashes. Each resulting line segment would then look something like the following:

    --xxxx----xxxx----xxxx--

    Note the half-gap (instead of a half-dash) at the start and end of the line segment.

    dashesAtCorners(document.querySelector('#one'), 5      );
    dashesAtCorners(document.querySelector('#two'), 5, true);
    
    function dashesAtCorners(polygon, aveDashSize, noneFlag) {
      const coordinates = c = polygon.getAttribute('points').replace(/,| +/g, ' ')
        .trim().split(' ').map(n => +n); // extract points' coordinates from polygon
      c.push(c[0], c[1]); // repeat the 1st point's coordinates at the end
      const dashes = d = noneFlag ? [0,0] : [0]; // if noneFlag, prepend extra zero
      for (s = 0; s < c.length - 2; s += 2) { // s is line segment number * 2
        const dx = c[s]-c[s+2], dy = c[s+1]-c[s+3], segLen = Math.sqrt(dx*dx+dy*dy),
          numDashes = n = Math.floor(0.5 + segLen / aveDashSize / 2),
          dashLen = len = segLen / n / 2; // calculate # of dashes & dash length
        d.push((d.pop() + len) / 2); // join prev seg's last dash, this seg's 1st dash
        (i => {while (i--) {d.push(len,len)}})(n); // fill out line with gaps & dashes
      }
      polygon.setAttribute('stroke-dasharray', d.join(' '));
    }
    <svg width="800" height="600" xmlns="http://www.w3.org/2000/svg">
      <g stroke="red" stroke-width="20" transform="scale(0.7)">
        <polygon id="one" transform="translate(-200, -40)"
                 points="350,75  379,161 469,161 397,215
                            423,301 350,250 277,301 303,215
                            231,161 321,161"
                            />
        <polygon id="two" transform="translate(100, -40)"
                 points="350,75  379,161 469,161 397,215
                            423,301 350,250 277,301 303,215
                            231,161 321,161"
                            />
      </g>
    </svg>

    The above strategy can be generally applied to any polygon element, symmetrical or unsymmetrical. For instance, try arbitrarily changing any of the coordinates of the example star and it'll still work. (Note, however, that making a corner too "pointy", i.e. with a very small angle, changes its stroke appearance such that the corner changes from "sharp" to "blunt". I don't know the general rules for that, e.g. angle threshold/cut-off. I also don't know whether there are browser differences in the implementation of this limitation. Just so you know. Other than that, however, the strategy is generally applicable to any polygon.)

    Paths (requires a polyfill, as of mid-February 2017)

    The above strategy can't, however, be applied exactly as written to path elements. One big difference is that polygons only have straight edges while paths can also have curves. Applying this strategy to path elements requires a modification that calculates lengths of path segments like the above strategy calculates lengths of straight polygon edges. For a path, you would need to retrieve individual (straight or curved) path segments, then use the getTotalLength method to determine the segment length. Then you would proceed with the above calculations in the same way that above code uses the length of each straight polygon edge. Alas, we're currently in a no man's land between 2 SVG APIs that could be used for this: an older deprecated one (pathSegList) and a not-yet-available replacement (getPathData). Fortunately, there are polyfills for both the older and newer APIs that can be used. Note that the getPathData polyfill cannot be used directly on <use> elements (although it could, I suppose, be used on the shape element in the <defs> section that the <use> element uses, though I haven't specifically checked this).

    The following image shows a screen capture from this jsFiddle using the polyfill for getPathData, etc..

    enter image description here

    Leaving aside the polyfill, the code from that jsFiddle is as follows:

    html:

    <span>Set average dash length:</span><input type="range" min="4" max="60" value="60"/>
    <span id="len">60</span>
    <svg width="800" height="600" xmlns="http://www.w3.org/2000/svg">
      <g stroke="red" stroke-width="20" transform="scale(0.7)">
        <path id="one" transform="translate(-200, -40)"
          d="M350,75 C
          360,140 420,161 469,161
          400,200 410,260 423,301
          380,270 330,270 277,301
          280,250 280,200 231,161
          280,160 320,140 350,75
          Z"
          />
        <path id="two" transform="translate(200, -40)"
          d="M350,75 C
          360,140 420,161 469,161
          400,200 410,260 423,301
          380,270 330,270 277,301
          280,250 280,200 231,161
          280,160 320,140 350,75
          Z"
          />
        <path id="three" transform="translate(600, -40)"
          d="M350,75 C
          360,140 420,161 469,161
          400,200 410,260 423,301
          380,270 330,270 277,301
          280,250 280,200 231,161
          280,160 320,140 350,75
          Z"
          />
      </g>
      <text transform="translate(55, 230)">Normal dashes</text>
      <text transform="translate(330, 230)">Dashes at corners</text>
      <text transform="translate(595, 230)">No dashes at corners</text>
    </svg>
    

    js:

    setDashes(60);
    
    document.querySelector('input').oninput = evt => {
        const dashLen = evt.target.value;
      document.querySelector('#len').innerHTML = dashLen;
      setDashes(dashLen);
    };
    
    function setDashes(dashLen) {
      document.querySelector('#one').setAttribute('stroke-dasharray', dashLen);
      dashesAtCorners(document.querySelector('#two'  ), dashLen      );
      dashesAtCorners(document.querySelector('#three'), dashLen, true);
    }
    
    function getSegLen(pathData, idx) {
      const svgNS = "http://www.w3.org/2000/svg";
      const currSeg = pathData[idx];
      const prevSeg = pathData[idx - 1];
      const prevSegVals = prevSeg.values;
      const startCoords =
        'M' +
        prevSegVals[prevSegVals.length - 2] +
        ',' +
        prevSegVals[prevSegVals.length - 1];
      const segData = currSeg.type + currSeg.values;
      const segD = startCoords + segData;
      const newElmt = document.createElementNS(svgNS, "path");
      newElmt.setAttributeNS(null, "d", segD);
      return newElmt.getTotalLength();
    }
    
    function dashesAtCorners(element, aveDashSize, noneFlag) {
      const pathData = element.getPathData();
      const dashes = d = noneFlag ? [0,0] : [0]; // if noneFlag, prepend extra zero
      const internalSegments = pathData.slice(1, -1);
        for (segNum = 1; segNum < pathData.length - 1; segNum += 1) {
        const segLen = getSegLen(pathData, segNum);
        const numDashes = Math.floor(0.5 + segLen / aveDashSize / 2);
        const dashLen = segLen / numDashes / 2;
        // calculate # of dashes & dash length
        dashes.push((dashes.pop() + dashLen) / 2); // join prev seg's last dash, this seg's 1st dash
        (dashNum => {while (dashNum--) {dashes.push(dashLen,dashLen)}})(numDashes); // fill out line with gaps & dashes
      }
      element.setAttribute('stroke-dasharray', dashes.join(' '));
    }