2

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?

Sage Gerard
  • 1,311
  • 8
  • 29
  • Make sure your star side lengths are congruent. Then make sure your dash pattern is accurately crafted so that positions of each dash are consistent for each side length. – Paul LeBeau Jan 05 '17 at 18:40
  • @PaulLeBeau Can that reasoning apply in general, for polylines made by user input? I would be surprised if I could establish these conditions for sides of variable length. – Sage Gerard Jan 05 '17 at 18:40
  • Not really. Not for polylines whose segments can be of arbitrary length. Would you want a dash pattern which varied in size over the length of the polyline? – Paul LeBeau Jan 05 '17 at 18:43
  • @PaulLeBeau It isn't my preference, but I'd have to escalate on this end to see if they would want that. Is that possible within a single element? – Sage Gerard Jan 05 '17 at 18:45
  • Technically yes. But you will end up with very long dash array sequences. – Paul LeBeau Jan 05 '17 at 18:49
  • @PaulLeBeau I appreciate your help with this, man. I think I have everything I need here. If you restate what you said as an answer I'll be happy to accept it. – Sage Gerard Jan 05 '17 at 18:55

3 Answers3

4

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(' '));
}
Community
  • 1
  • 1
Andrew Willems
  • 11,880
  • 10
  • 53
  • 70
  • 1
    Thank you for volunteering an interesting solution! Is this a general solution that can be applied to arbitrary `` elements, or does it only apply to symmetrical polygons? – Sage Gerard Jan 10 '17 at 15:16
  • 1
    @SageGerard, see the extra two last paragraphs I've added to my answer to address your comment. Quick answer: as written, the solution is applicable to any arbitrary polygon (symmetrical or not), but not to paths, though conceptually it could be. – Andrew Willems Jan 11 '17 at 01:49
  • 1
    @SageGerard, I have now updated my answer to use the `getPathData` polyfill to apply this strategy to `` elements including those with curved path segments. – Andrew Willems Feb 20 '17 at 15:45
  • 1
    Outstanding work, and a very underappreciated answer. Thank you for your dedication and willingness to tackle a hard problem! – Sage Gerard Feb 20 '17 at 16:00
2

For the case of a general polyline with varying line segment lengths, the answer is "not easily".

Technically it is possible, but you would have to create a dash pattern that was the total length of the polyline and listed every dash segment individually. With each section of the pattern crafted to fit the length of each line segment. That will result in very long dash patterns. And obviously it would need to be individually generated for every polyline.

<svg width="400" height="400">
  
  <polygon points="50,100, 200,300, 350,300, 350,100"
           stroke="red" stroke-width="30"
           stroke-dasharray="50 50 50 50
                             100
                             50
                             90
                             40 40 40
                             100
                             60 60 60 60 0"/>
  
</svg>
Paul LeBeau
  • 97,474
  • 9
  • 154
  • 181
0

One possible workaround is to use markers to obscure the drawing inconsistencies:

<svg width="800" height="600" xmlns="http://www.w3.org/2000/svg"> 
  <defs>
     <marker id="red-circle"
      viewBox="0 0 10 10" refX="5" refY="5" orient="auto" >
         <circle fill="red" cx="5" cy="5" r="5"/>
      </marker>
    
  </defs>
  <polygon filter="url(#trythis)" 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" 
                      marker-mid="url(#red-circle)"                                           
                      marker-end="url(#red-circle)"/>
</svg>
Michael Mullany
  • 30,283
  • 6
  • 81
  • 105