2
\$\begingroup\$

A need

I have always found dynamically creating SVG content and filters a painful and messy process.

  • The constant need for conversion from JavaScript values to SVG and back,
  • Access to the SVG attributes via the setAttribute and setAttribute makes code a mess.
  • Remembering which property names use which naming convention. Eg clip-path and clipPathUnit are correct while clipPath and clip-path-unit are incorrect.

For these and other reasons I have generally stayed away from SVG, but as the HTML canvas CanvasRenderingContext2D now has good support for the filter property, filling a MUCH needed hole in the API, I find my self writting SVG content more and more.

So after having a particular nasty bug hidden in the mess of SVG code I decided to write the following.

The code for review.

const createSVG = (()=>{
    /* This code uses some abreviations
       str is string
       arr is array
       num is number
       prop is property
       props is properties
       2 for conversion eg str2Num is string to number
    */
    var   id = 0;
    var   units = "";
    const svgNamespace = "http://www.w3.org/2000/svg";
    const transformTypes = {read : "read", write : "write"};
    const transformPropsName = "accent-height,alignment-baseline,arabic-form,baseline-shift,cap-height,clip-path,clip-rule,color-interpolation,color-interpolation-filters,color-profile,color-rendering,dominant-baseline,enable-background,fill-opacity,fill-rule,flood-color,flood-opacity,font-family,font-size,font-size-adjust,font-stretch,font-style,font-variant,font-weight,glyph-name,glyph-orientation-horizontal,glyph-orientation-vertical,horiz-adv-x,horiz-origin-x,image-rendering,letter-spacing,lighting-color,marker-end,marker-mid,marker-start,overline-position,overline-thickness,panose-1,paint-order,pointer-events,rendering-intent,shape-rendering,stop-color,stop-opacity,strikethrough-position,strikethrough-thickness,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,stroke-width,text-anchor,text-decoration,text-rendering,underline-position,underline-thickness,unicode-bidi,unicode-range,units-per-em,v-alphabetic,v-hanging,v-ideographic,v-mathematical,vert-adv-y,vert-origin-x,vert-origin-y,word-spacing,writing-mode,x-height";
    const unitPropsNames ="width,height,x,y,z,x1,x2,y1,y2,cx,cy,rx,ry,r,accentHeight,alignmentBaseline,baselineShift,capHeight,fontSize,fontSizeAdjust,overlinePosition,overlineThickness,strikethroughPosition,strikethroughThickness,strokeWidth,underlinePosition,underlineThickness,vertOriginX,vertOriginY,wordSpacing,xHeight";

    /* Transform helper functions */
    const onlyArr2Str = (value, points = false) => {
        if (points) {
            if (Array.isArray(value)) {
                return value.map(point => Array.isArray(point) ? point.join(",") : point).join(" ");
            }
            return value;
        }
        return Array.isArray(value) ? value.join(" ") : value
    }

    /* Value transform functions */
    const arr2Str      = value => onlyArr2Str(value);
    const str2NumArr   = value => value.split(" ").map(value => Number(value));
    const unitStr2Num  = value => Number(value.replace(/[a-z]/gi, ""));
    const str2Num      = value => Number(value);
    const str2NumOrStr = value => isNaN(value) ? value : Number(value);
    const num2UnitStr  = value => value + units;
    const num2Percent  = value => value + "%";
    const pointArr2Str = value => onlyArr2Str(value, true);
    const url2Str      = value => value.replace(/url\(#|)/g, "");
    const ref2Url      = value => {
        if (typeof value === "string") {
            if (value.indexOf("url(#") > -1) { return value }
            return `url(#${value})`;
        }
        if (value.isPSVG) {
            if (value.node.id) { return `url(#${value.node.id})` }
            value.node.id = "PSVG_ID_"+ (id ++);
            return `url(#${value.node.id})`;
        }
        return value;
    };
    const str2PointArr = value => value.split(" ").map(point => {
        point = point.split(",");
        point[0] = Number(point[0]);
        point[1] = Number(point[1]);
        return point;
    });

    /* property value transforms `read` from SVG `write` to SVG */
    const transforms = {
        read : {
            offset      : unitStr2Num,
            points      : str2PointArr,
            filter      : url2Str,
            clipPath    : url2Str,
            stdDeviation: str2Num,
            dx          : str2Num,  
            dy          : str2Num, 
            tableValues : str2NumArr,
            values      : str2NumArr,
            kernelMatrix: str2NumArr,
            viewbox     : str2NumArr,
            _default    : str2NumOrStr, 
        },
        write : {
            points      : pointArr2Str,
            offset      : num2Percent,
            filter      : ref2Url,
            clipPath    : ref2Url,
            tableValues : arr2Str,
            values      : arr2Str,
            kernelMatrix: arr2Str,
            viewbox     : arr2Str,
            _default(value) { return value },
        },
    }

    /* Assign additional unit value transforms */
    unitPropsNames.split(",").forEach((propName) => {
        transforms.read[propName] = unitStr2Num;
        transforms.write[propName] = num2UnitStr;
    });

    /* Create property name transform lookups */
    const propNodeNames = transformPropsName.split(",");
    const propScriptNames = transformPropsName.replace(/-./g, str => str[1].toUpperCase()).split(",");

    /* returns transformed `value` of associated property `name`  depending on `[type]` default write*/
    function transform(name, value, type = transformTypes.write) {
        return transforms[type][name] ? transforms[type][name](value) : transforms[type]._default(value);
    }

    /* returns Transformed JavaScript property name as SVG property name if needed. EG "fillRule" >> "fill-rule" */
    function propNameTransform(name) {
        const index = propScriptNames.indexOf(name);
        return index === -1 ? name : propNodeNames[index];
    }

    /* node creation function returned as the interface instanciator of the node proxy */
    /* type String representing the node type.
       props optional Object containing node properties to set
       returns a proxy holding the node */
    const createSVG = (type, props = {}) => {
        const PSVG = (()=>{  // PSVG is abreviation for Practical SVG 
            const node = document.createElementNS(svgNamespace, type);
            const set  = (name, value) => node.setAttribute(propNameTransform(name), transform(name, value));
            const get  = (name, value) => transform(name, node.getAttribute(propNameTransform(name)), transformTypes.read);
            const svg  = {
                isPSVG   : true,
                nodeType : type,
                node     : node,
                set units(postFix) { units = postFix },
                get units() { return units },
            };
            const proxyHandler = {
                get(target, name) { return svg[name] !== undefined ? target[name] : get(name) },
                set(target, name, value) {
                    if (value !== null && typeof value === "object" && value.isPSVG) {
                        node.appendChild(value.node);
                        target[name] = value;
                        return true;
                    }
                    set(name,value);
                    return true;
                },
            };
            return new Proxy(svg, proxyHandler);
        })();
        Object.keys(props).forEach(key => PSVG[key] = props[key]);
        return PSVG;
    }
    return createSVG;
})();
export default createSVG;

Why review

My SVG experience is low, and I am not at all sure if this is safe or even practical for the wild.

Any comments, suggestions, warnings, or improvements would be appreciated.

How it works.

It creates XML nodes and returns a proxy of an object holding the node. The proxy get and set handlers do the hard work of transforming property names and property values between JavaScript friendly and XML formats, and performing the correct action depending on property type.

  • Unit values are transformed to and from javascript Number. eg "20px" becomes 20
  • Array values are transformed to appropriate strings and back
  • Property names are converted to SVG property names if needed "strokeWidth" becomes "stroke-width"
  • Reference values are transformed appropriately depending on assignment type.. eg svg.circle.filter = svg.blurFilter // <circle filter="url(#blur)"> the blurfilter id is referenced.
  • Assigning a node to a property name also appends the node. Nodes can be accessed as properties. eg svg.circle = createSVG("circle"); A new circle node is appended to the svg node. svg.circle.r correctly access the circle node's radius property.

Example usages

 //=====================================================
 // Create a node.
 const svg = createSVG("svg",{width : 100, height : 100});

 //=====================================================
 // Add a node 
 const svg.circle = createSVG("circle");

 //=====================================================
 // Add and set property
 svg.circle.cx = 50;  // Note that default units is px
 svg.circle.cx = 50;
 svg.circle.r = 30;
 svg.circle.fill = "Green";
 svg.circle.stroke = "black";

 //=====================================================
 // Transforming property name
 svg.circle.strokeWidth = 3; // SVG circle property name "stroke-width"

 // XML result of circle
 // <circle cx="50px" cy="50px" r="30px" fill="Green" stroke="black" stroke-width="3px"></circle>

 //=====================================================
 // Modify a property 
 svg.circle.r += 10;

 //=====================================================
 // Array value transformation 
 svg.polygon = createSVG("polygon");

 // array to string transform
 // "0,0 100,0 100,100 0,100";
 svg.polygon.points = [[0,0],[100,0],[100,100],[0,100]];

 // string to array transform
 const pointsArray = svg.polygon.points;

 //=====================================================
 // node access
 svg.text = createSVG("text");
 svg.text.node.textContent = "Hi World";     


 //=====================================================
 // Adding to DOM
 document.appendChild(svg.node);     

Running example

The following snippet contains an example use. Creates a SVG node, adds to DOM, small delay then updates node properties, adds additional nodes, and starts an Javascript controlled animation.

"use strict";
/* Example usage */
setTimeout(()=>{
    const width = 100;
    const height = 100;
    const resizeBy = 10;
    const pathPoints = [[0,0], [100,0], [50,50], [100,100], [0,100], [50,50], [0,0]];
    const pathStyle = {points : pathPoints, fill : "orange", stroke : "black", strokeWidth : 3 };
    
    // ======================================================================= 
    // createSVG takes two arguments
    // The node type as a string
    // optional object containing normalized properties and values
    const svg = createSVG("svg", {width : width, height : height});
    // create a polygon node
    svg.polygon = createSVG("polygon", pathStyle);

    // =======================================================================   
    // add svg node to DOM
    exampleSVG.appendChild(svg.node);
    XMLResult.textContent = exampleSVG.innerHTML;
    
    
    // =======================================================================
    // Two seconds then change some properties and add new nodes    
    setTimeout(() => {
      infoElement.textContent = "SVG properties updated and nodes added. Javascript animation";
      // resize SVG 
      var extraSize = (svg.polygon.strokeWidth + 2) * 2;
      svg.width  += resizeBy + extraSize;  // The proxy get converts string to NUMBER and
      svg.height += resizeBy + extraSize;  // the converts back to string and append units if used
      
      // The path.points as a SVG string is converted back to array of points
      // the array is assigned to point and then converted back to a points string
      svg.polygon.points = svg.polygon.points.map(point => (point[0] += 10, point[1] += 10, point));    
      
      // get polygon node "stroke-width" converts to Number add 2 and sets new value
      svg.polygon.strokeWidth += 2;
      // change the fill.
      svg.polygon.fill = "Green";
      
      // Append a new circle object to the svg
      svg.circle = createSVG("circle");
      svg.circle.cx = svg.width / 2;
      svg.circle.cy = svg.height / 2;
      svg.circle.r = Math.min(svg.width, svg.height) / 3;
      svg.circle.fill = "orange";

      // Example of setting node content      
      svg.text = createSVG("text",{x : 25, y : 20, fontFamily : "Verdana", fontSize : "10", fill : "white"});
      // Each PSVG object has node property that is the actual XML node and its properties
      // can be set directly
      svg.text.node.textContent = "Some text.";
      

      // Animate circle
      requestAnimationFrame(animate);
    },2000);

    //=========================================================================
    /* JAVASCRIPT driven animation of SVG */
    function animate(time){
        var x = svg.width / 2
        var y = svg.height / 2
        var rad = Math.cos(time / 2000) * Math.min(x,y) * (1/4) + Math.min(x,y)  * (1/2);
        svg.circle.r = rad;
        x += Math.cos(time / 1000) * rad * (1/3);
        y += Math.sin(time / 1000) * rad * (1/3);
        svg.circle.cx = x;
        svg.circle.cy = y;        
        XMLResult.textContent = exampleSVG.innerHTML;
        requestAnimationFrame(animate);   
    }
},0)




/* =============================================================================
createSVG is module for review 
===============================================================================*/
const createSVG = (()=>{
    /* This code uses some abreviations
       str is string
       arr is array
       num is number
       prop is property
       props is properties
       2 for conversion eg str2Num is string to number
    */
    var   id = 0;
    var   units = "";
    const svgNamespace = "http://www.w3.org/2000/svg";
    const transformTypes = {read : "read", write : "write"};
    const transformPropsName = "accent-height,alignment-baseline,arabic-form,baseline-shift,cap-height,clip-path,clip-rule,color-interpolation,color-interpolation-filters,color-profile,color-rendering,dominant-baseline,enable-background,fill-opacity,fill-rule,flood-color,flood-opacity,font-family,font-size,font-size-adjust,font-stretch,font-style,font-variant,font-weight,glyph-name,glyph-orientation-horizontal,glyph-orientation-vertical,horiz-adv-x,horiz-origin-x,image-rendering,letter-spacing,lighting-color,marker-end,marker-mid,marker-start,overline-position,overline-thickness,panose-1,paint-order,pointer-events,rendering-intent,shape-rendering,stop-color,stop-opacity,strikethrough-position,strikethrough-thickness,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,stroke-width,text-anchor,text-decoration,text-rendering,underline-position,underline-thickness,unicode-bidi,unicode-range,units-per-em,v-alphabetic,v-hanging,v-ideographic,v-mathematical,vert-adv-y,vert-origin-x,vert-origin-y,word-spacing,writing-mode,x-height";
    const unitPropsNames ="width,height,x,y,z,x1,x2,y1,y2,cx,cy,rx,ry,r,accentHeight,alignmentBaseline,baselineShift,capHeight,fontSize,fontSizeAdjust,overlinePosition,overlineThickness,strikethroughPosition,strikethroughThickness,strokeWidth,underlinePosition,underlineThickness,vertOriginX,vertOriginY,wordSpacing,xHeight";
   
    /* Transform helper functions */
    const onlyArr2Str = (value, points = false) => {
        if (points) {
            if (Array.isArray(value)) {
                return value.map(point => Array.isArray(point) ? point.join(",") : point).join(" ");
            }
            return value;
        }
        return Array.isArray(value) ? value.join(" ") : value
    }
    
    /* Value transform functions */
    const arr2Str      = value => onlyArr2Str(value);
    const str2NumArr   = value => value.split(" ").map(value => Number(value));
    const unitStr2Num  = value => Number(value.replace(/[a-z]/gi, ""));
    const str2Num      = value => Number(value);
    const str2NumOrStr = value => isNaN(value) ? value : Number(value);
    const num2UnitStr  = value => value + units;
    const num2Percent  = value => value + "%";
    const pointArr2Str = value => onlyArr2Str(value, true);
    const url2Str      = value => value.replace(/url\(#|)/g, "");
    const ref2Url      = value => {
        if (typeof value === "string") {
            if (value.indexOf("url(#") > -1) { return value }
            return `url(#${value})`;
        }
        if (value.isPSVG) {
            if (value.node.id) { return `url(#${value.node.id})` }
            value.node.id = "PSVG_ID_"+ (id ++);
            return `url(#${value.node.id})`;
        }
        return value;
    };
    const str2PointArr = value => value.split(" ").map(point => {
        point = point.split(",");
        point[0] = Number(point[0]);
        point[1] = Number(point[1]);
        return point;
    });
    
    /* property value transforms `read` from SVG `write` to SVG */
    const transforms = {
        read : {
            offset      : unitStr2Num,
            points      : str2PointArr,
            filter      : url2Str,
            clipPath    : url2Str,
            stdDeviation: str2Num,
            dx          : str2Num,  
            dy          : str2Num, 
            tableValues : str2NumArr,
            values      : str2NumArr,
            kernelMatrix: str2NumArr,
            viewbox     : str2NumArr,
            _default    : str2NumOrStr, 
        },
        write : {
            points      : pointArr2Str,
            offset      : num2Percent,
            filter      : ref2Url,
            clipPath    : ref2Url,
            tableValues : arr2Str,
            values      : arr2Str,
            kernelMatrix: arr2Str,
            viewbox     : arr2Str,
            _default(value) { return value },
        },
    }
    
    /* Assign additional unit value transforms */
    unitPropsNames.split(",").forEach((propName) => {
        transforms.read[propName] = unitStr2Num;
        transforms.write[propName] = num2UnitStr;
    });
    
    /* Create property name transform lookups */
    const propNodeNames = transformPropsName.split(",");
    const propScriptNames = transformPropsName.replace(/-./g, str => str[1].toUpperCase()).split(",");

    /* returns transformed `value` of associated property `name`  depending on `[type]` default write*/
    function transform(name, value, type = transformTypes.write) {
        return transforms[type][name] ? transforms[type][name](value) : transforms[type]._default(value);
    }
    
    /* returns Transformed JavaScript property name as SVG property name if needed. EG "fillRule" >> "fill-rule" */
    function propNameTransform(name) {
        const index = propScriptNames.indexOf(name);
        return index === -1 ? name : propNodeNames[index];
    }
    
    /* node creation function returned as the interface instanciator of the node proxy */
    /* type String representing the node type.
       props optional Object containing node properties to set
       returns a proxy holding the node */
    const createSVG = (type, props = {}) => {
        const PSVG = (()=>{  // PSVG is abreviation for Practical SVG 
            const node = document.createElementNS(svgNamespace, type);
            const set  = (name, value) => node.setAttribute(propNameTransform(name), transform(name, value));
            const get  = (name, value) => transform(name, node.getAttribute(propNameTransform(name)), transformTypes.read);
            const svg  = {
                isPSVG   : true,
                nodeType : type,
                node     : node,
                set units(postFix) { units = postFix },
                get units() { return units },
            };
            const proxyHandler = {
                get(target, name) { return svg[name] !== undefined ? target[name] : get(name) },
                set(target, name, value) {
                    if (value !== null && typeof value === "object" && value.isPSVG) {
                        node.appendChild(value.node);
                        target[name] = value;
                        return true;
                    }
                    set(name,value);
                    return true;
                },
            };
            return new Proxy(svg, proxyHandler);
        })();
        Object.keys(props).forEach(key => PSVG[key] = props[key]);
        return PSVG;
    }
    return createSVG;
})();
body {
  font-family : arial;
}
#XMLResult {
  font-family : consola;
  font-size : 12px;
}
<span id="infoElement">SVG node added. In two seconds is modified.</span><br>
<div id="exampleSVG"></div>
The XML;
<div id="XMLResult"></div>

Known issuses

  • Slow, the proxy handlers hide a lot of executed code.
  • Reference loss. Assigning nodes currently does not test for existing and adds nodes. Any existing named node's reference is lost. eg svg.a = createSVG("a"); svg.a = createSVG("a"); creates two nodes but only one reference.
  • Incomplete property value transforms. Unknown properties are assigned as is.
  • Very little testing, I wrote it over the weekend.
\$\endgroup\$
2
\$\begingroup\$

General Feedback

I can't claim to have done much with SVG creation myself but I can offer a few review points for the code that creates the SVG item. It looks like you have done a lot to abstract out the common SVG code necessary for your needs. There is good usage of const for values that don't get re-assigned, and let for other variables. And it is good that there are no nested ternary operators.

Review points

There are quite a few lines that check if arguments are arrays or not... For instance, onlyArr2Str checks if value is an array, and then if it is an array, checks if each element is an array (in the .map() callback). It appears that function is only called twice (at least with the sample code), and each time value is an array of arrays. Are there cases where that is not the case?


str2Num could just simply be replaced with Number - and similarly, this line within onlyArr2Str():

const str2NumArr   = value => value.split(" ").map(value => Number(value));

Can be simplified to:

const str2NumArr   = value => value.split(" ").map(Number);

Why not use arrow function syntax for animate, transform and propNameTransform? Was the goal to keep the scope of the variables inside contained?


At least one property of the object returned by the proxy (i.e. svg in PSVG()) can be shortened using shorthand property names because the property name is the same as the variable name:

const svg  = {
    isPSVG   : true,
    nodeType : type,
    node,

And the same could be true for nodeType if the first argument of the createSVG function (i.e. type) is renamed accordingly.


For this line:

Object.keys(props).forEach(key => PSVG[key] = props[key]);

Correct me if this is wrong, but it appears this could be simplified by using Object.assign():

  Object.assign(PSVG, props);
\$\endgroup\$
  • 1
    \$\begingroup\$ Thanks, your have highlighted points that would have been obvious in another's code but that I was blind too in my own. Re Arrow / Expression functions... There is no good reason apart from a habit I have of defining outside functions called from inside an object so that they may be assigned to the object and carry the correct this which does not apply in this case. Code reviews are worth the time to ask for. \$\endgroup\$ – Blindman67 Jun 2 '18 at 6:59

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.